Skip to content
Open
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
108 changes: 108 additions & 0 deletions .github/scripts/create-bot-changeset.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
#!/usr/bin/env bash
set -euo pipefail

# Creates a changeset for bot PRs (Dependabot, Aikido, etc.)
# Detects which dependencies were updated and which packages use them.
#
# Usage:
# .github/scripts/create-bot-changeset.sh <base-ref> <pr-number> <pr-title>
#
# Example (local testing):
# .github/scripts/create-bot-changeset.sh main 9999 "Bump minimatch"
#
# Set DRY_RUN=1 to preview without writing files:
# DRY_RUN=1 .github/scripts/create-bot-changeset.sh main 9999 "Bump minimatch"

BASE="${1:?Usage: $0 <base-ref> <pr-number> <pr-title>}"
PR_NUMBER="${2:?Usage: $0 <base-ref> <pr-number> <pr-title>}"
PR_TITLE="${3:?Usage: $0 <base-ref> <pr-number> <pr-title>}"
DRY_RUN="${DRY_RUN:-0}"

CHANGESET_NAME="bot-pr-${PR_NUMBER}"
CHANGESET_FILE=".changeset/${CHANGESET_NAME}.md"

# Check for existing changeset
if [ -f "$CHANGESET_FILE" ]; then
echo "Changeset already exists: ${CHANGESET_FILE}"
exit 0
fi

# Collect updated dependency names from all sources
UPDATED_DEPS=""

# 1. From yarn.lock diff — extract package names from added resolution lines
# Format: + resolution: "package-name@npm:x.y.z"
LOCKFILE_DEPS=$(git diff "${BASE}...HEAD" -- yarn.lock | grep -E '^\+\s+resolution:' | sed -E 's/.*"([^@]+)@.*/\1/' | sort | uniq || true)
UPDATED_DEPS="${UPDATED_DEPS}${LOCKFILE_DEPS}"

# 2. From any changed package.json files — extract dependency names from added lines
PKG_JSON_DEPS=$(git diff "${BASE}...HEAD" -- '*/package.json' 'package.json' | grep -E '^\+\s+"[^"]+": "[\^~]?[0-9]' | sed -E 's/^\+\s+"([^"]+)".*/\1/' | sort | uniq || true)
if [ -n "$PKG_JSON_DEPS" ]; then
UPDATED_DEPS="${UPDATED_DEPS}\n${PKG_JSON_DEPS}"
fi

UPDATED_DEPS=$(echo -e "$UPDATED_DEPS" | sort | uniq | grep -v '^$' || true)

if [ -z "$UPDATED_DEPS" ]; then
echo "No updated dependencies detected."
exit 0
fi

echo "Updated dependencies:"
echo "$UPDATED_DEPS"
echo ""

# Resolve transitive dependency graph via yarn.lock to find affected workspace packages
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck disable=SC2086
PACKAGES=$(node "${SCRIPT_DIR}/resolve-affected-packages.js" $UPDATED_DEPS)

# Filter to only publishable Nx packages (those with a project.json)
FILTERED=""
for pkg in $PACKAGES; do
for PROJECT_JSON in $(find packages -name project.json -not -path '*/node_modules/*'); do
PKG_DIR=$(dirname "$PROJECT_JSON")
PKG_JSON="${PKG_DIR}/package.json"
[ -f "$PKG_JSON" ] || continue

PKG_NAME=$(jq -r '.name' "$PKG_JSON")
if [ "$PKG_NAME" = "$pkg" ]; then
FILTERED="${FILTERED}${pkg}\n"
break
fi
done
done

PACKAGES=$(echo -e "$FILTERED" | sort | uniq | grep -v '^$' || true)

if [ -z "$PACKAGES" ]; then
echo "No publishable packages affected."
exit 0
fi

echo "Affected packages:"
echo "$PACKAGES"
echo ""

# Build the changeset
CHANGESET_CONTENT=$(
{
echo "---"
echo "$PACKAGES" | while IFS= read -r pkg; do
[ -z "$pkg" ] && continue
echo "\"$pkg\": patch"
done
echo "---"
echo ""
echo "$PR_TITLE"
}
)

if [ "$DRY_RUN" = "1" ]; then
echo "DRY RUN — would create ${CHANGESET_FILE}:"
echo "$CHANGESET_CONTENT"
else
echo "$CHANGESET_CONTENT" > "$CHANGESET_FILE"
echo "Created ${CHANGESET_FILE}:"
cat "$CHANGESET_FILE"
fi
107 changes: 107 additions & 0 deletions .github/scripts/resolve-affected-packages.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
#!/usr/bin/env node

/**
* Resolves which workspace packages are transitively affected by updated dependencies.
*
* Parses yarn.lock (Yarn Berry format) to build the dependency graph, then walks
* upward from the updated deps to find which workspace packages depend on them.
*
* Usage: node resolve-affected-packages.js <dep1> <dep2> ...
* Output: one workspace package name per line
*/

const fs = require("fs");

const updatedDeps = process.argv.slice(2);
if (updatedDeps.length === 0) {
process.exit(0);
}

const lockfile = fs.readFileSync("yarn.lock", "utf8");

// Parse yarn.lock into entries separated by blank lines
const blocks = lockfile.split(/\n\n+/).filter((b) => b.trim());

// For each entry, extract: package name, whether it's a workspace package, and its dependencies
const entries = [];

for (const block of blocks) {
const lines = block.split("\n");
const header = lines[0];

// Skip metadata lines (comments, __metadata, etc.)
if (!header.startsWith('"')) continue;

// Extract package name from header
// Format: "@scope/name@npm:^1.0.0, @scope/name@npm:^1.1.0":
// or: "@scope/name@workspace:packages/foo":
const nameMatch = header.match(/^"(@?[^@]+)@/);
if (!nameMatch) continue;

const pkgName = nameMatch[1];

// Check if workspace package
const isWorkspace = header.includes("@workspace:");

// Extract dependencies
const deps = [];
let inDeps = false;
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
if (/^\s+dependencies:/.test(line)) {
inDeps = true;
continue;
}
if (inDeps) {
const depMatch = line.match(/^\s{4}([^\s:]+):/);
if (depMatch) {
deps.push(depMatch[1]);
} else {
inDeps = false;
}
}
}

entries.push({ pkgName, isWorkspace, deps });
}

// Build reverse dependency map: dep name -> set of package names that depend on it
const reverseDeps = new Map();
for (const entry of entries) {
for (const dep of entry.deps) {
if (!reverseDeps.has(dep)) reverseDeps.set(dep, new Set());
reverseDeps.get(dep).add(entry.pkgName);
}
}

// BFS upward from updated deps to find affected workspace packages
const visited = new Set();
const queue = [...updatedDeps];
const affectedWorkspaces = new Set();

// Index workspace packages by name for quick lookup
const workspaceNames = new Set(
entries.filter((e) => e.isWorkspace).map((e) => e.pkgName)
);

while (queue.length > 0) {
const dep = queue.shift();
if (visited.has(dep)) continue;
visited.add(dep);

const dependents = reverseDeps.get(dep);
if (!dependents) continue;

for (const dependent of dependents) {
if (workspaceNames.has(dependent)) {
affectedWorkspaces.add(dependent);
} else if (!visited.has(dependent)) {
queue.push(dependent);
}
}
}

// Output sorted workspace package names
for (const pkg of [...affectedWorkspaces].sort()) {
console.log(pkg);
}
54 changes: 54 additions & 0 deletions .github/workflows/bot-changeset.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
name: 🤖 Bot Changeset

on:
pull_request:
types: [opened, synchronize]

permissions:
contents: write
pull-requests: read

jobs:
create-changeset:
name: Create Changeset
runs-on: ubuntu-latest
if: >-
github.event.pull_request.user.type == 'Bot' &&
(github.event.pull_request.user.login == 'dependabot[bot]' ||
github.event.pull_request.user.login == 'aikido-autofix[bot]')
steps:
- name: Checkout Repository
uses: actions/checkout@v6
with:
ref: ${{ github.head_ref }}
fetch-depth: 0

- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: ".nvmrc"

- name: Fetch base ref
run: git fetch origin ${{ github.base_ref }}

- name: Create changeset
env:
BASE_REF: ${{ github.base_ref }}
PR_TITLE: ${{ github.event.pull_request.title }}
run: |
.github/scripts/create-bot-changeset.sh \
"origin/$BASE_REF" \
"${{ github.event.pull_request.number }}" \
"$PR_TITLE"

- name: Commit and push changeset
run: |
git add .changeset/
if git diff --cached --quiet; then
echo "No changeset to commit."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git commit -m "Add changeset for bot PR"
git push