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
23 changes: 21 additions & 2 deletions .github/workflows/pi-subagents-upgrade.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ jobs:
- name: Update pi-subagents dependency metadata
id: update
env:
GITHUB_TOKEN: ${{ github.token }}
PI_SUBAGENTS_VERSION: ${{ inputs.pi-subagents-version }}
VALIDATE_ONLY: ${{ inputs.validate-only || false }}
run: |
Expand Down Expand Up @@ -147,6 +148,25 @@ jobs:
app-id: ${{ secrets.RELEASE_PLEASE_BOT_APP_ID }}
private-key: ${{ secrets.RELEASE_PLEASE_BOT_PRIVATE_KEY }}

- name: Reuse open pi-subagents upgrade PR branch
id: pr-branch
if: >-
steps.update.outputs.no_update != 'true' &&
steps.update.outputs.validate_only != 'true'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
branch="$(
gh pr list \
--repo "$GITHUB_REPOSITORY" \
--base main \
--state open \
--limit 100 \
--json headRefName \
--jq '[.[] | select(.headRefName | startswith("automation/pi-subagents-"))][0].headRefName // ""'
)"
echo "branch=${branch:-automation/pi-subagents-upgrade}" >> "$GITHUB_OUTPUT"

- name: Create or update pi-subagents upgrade PR
if: >-
steps.update.outputs.no_update != 'true' &&
Expand All @@ -159,8 +179,7 @@ jobs:
package-lock.json
npm-shrinkwrap.json
nix/package.nix
branch:
automation/pi-subagents-${{ steps.update.outputs.target_version }}
branch: ${{ steps.pr-branch.outputs.branch }}
delete-branch: true
title: >-
chore(deps): update pi-subagents to ${{
Expand Down
95 changes: 94 additions & 1 deletion scripts/pi-subagents-upgrade-lib.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export const PI_SUBAGENTS_PACKAGE = "pi-subagents";
export const PI_SUBAGENTS_REPOSITORY = "nicobailon/pi-subagents";

const stableVersionPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/u;

Expand Down Expand Up @@ -43,6 +44,86 @@ export async function fetchLatestPiSubagentsVersion(fetchImpl = fetch) {
);
}

function versionFromReleaseTag(tagName) {
if (typeof tagName !== "string") return undefined;
const match = tagName.match(
/^v((?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*))$/u,
);
return match?.[1];
}

export async function fetchPiSubagentsReleaseNotes({
currentVersion,
targetVersion,
fetchImpl = fetch,
token,
}) {
const current = normalizePiSubagentsVersion(
currentVersion,
"Current pi-subagents version",
);
const target = normalizePiSubagentsVersion(
targetVersion,
"Target pi-subagents version",
);
const releaseNotes = new Map();

for (let page = 1; ; page += 1) {
const headers = {
Accept: "application/vnd.github+json",
"User-Agent": "patchmill-pi-subagents-upgrade",
"X-GitHub-Api-Version": "2022-11-28",
};
if (token) headers.Authorization = `Bearer ${token}`;
const response = await fetchImpl(
`https://api.github.com/repos/${PI_SUBAGENTS_REPOSITORY}/releases?per_page=100&page=${page}`,
{ headers },
);
if (!response.ok) {
throw new Error(
`${PI_SUBAGENTS_PACKAGE}: GitHub releases request failed (${response.status})`,
);
}
const releases = await response.json();
if (!Array.isArray(releases)) {
throw new Error(
`${PI_SUBAGENTS_PACKAGE}: GitHub releases response must be an array`,
);
}

for (const release of releases) {
const version = versionFromReleaseTag(release?.tag_name);
if (
!version ||
release.draft ||
release.prerelease ||
comparePiSubagentsVersions(version, current) <= 0 ||
comparePiSubagentsVersions(version, target) > 0
) {
continue;
}
releaseNotes.set(version, {
version,
url:
release.html_url ??
`https://github.com/${PI_SUBAGENTS_REPOSITORY}/releases/tag/v${version}`,
body: release.body?.trim() || "_No release notes were provided._",
});
}

if (releases.length < 100) break;
}

if (!releaseNotes.has(target)) {
throw new Error(
`${PI_SUBAGENTS_PACKAGE}: no GitHub release notes found for v${target}`,
);
}
return [...releaseNotes.values()].sort((left, right) =>
comparePiSubagentsVersions(left.version, right.version),
);
}

export function resolvePiSubagentsUpgrade({
currentVersion,
latestVersion,
Expand Down Expand Up @@ -112,5 +193,17 @@ export function renderPiSubagentsPullRequestBody(summary) {
const validation = (summary.validationCommands ?? [])
.map((command) => `- \`${command}\``)
.join("\n");
return `## pi-subagents dependency upgrade\n\nUpdates \`${PI_SUBAGENTS_PACKAGE}\` from \`${summary.currentVersion}\` to \`${summary.targetVersion}\`.\n\n## Changed files\n\n${changedFiles}\n\n## Validation\n\n${validation}\n\nThis pull request is review-gated and does not auto-merge or publish.\n`;
const releaseNotes = (summary.releaseNotes ?? [])
.map(
(release) =>
`<details>\n<summary><a href="${release.url}">v${release.version}</a></summary>\n\n${release.body}\n\n</details>`,
)
.join("\n\n");
const body = `## pi-subagents dependency upgrade\n\nUpdates \`${PI_SUBAGENTS_PACKAGE}\` from \`${summary.currentVersion}\` to \`${summary.targetVersion}\`.\n\n## Release notes\n\n${releaseNotes}\n\n## Changed files\n\n${changedFiles}\n\n## Validation\n\n${validation}\n\nThis pull request is review-gated and does not auto-merge or publish.\n`;
if (body.length > 65_536) {
throw new Error(
"pi-subagents pull-request body exceeds GitHub's 65,536-character limit",
);
}
return body;
}
84 changes: 83 additions & 1 deletion scripts/pi-subagents-upgrade-lib.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { test } from "node:test";
import {
assertLockfilesMatchPiSubagentsTarget,
fetchLatestPiSubagentsVersion,
fetchPiSubagentsReleaseNotes,
getCurrentPiSubagentsVersion,
renderPiSubagentsPullRequestBody,
resolvePiSubagentsUpgrade,
Expand Down Expand Up @@ -79,6 +80,58 @@ test("npm latest resolution validates the registry response", async () => {
assert.equal(version, "0.40.0");
});

test("release notes include every stable release in the version jump", async () => {
let request;
const releaseNotes = await fetchPiSubagentsReleaseNotes({
currentVersion: "0.39.0",
targetVersion: "0.41.0",
token: "test-token",
fetchImpl: async (url, options) => {
request = { url, options };
return {
ok: true,
json: async () => [
{
tag_name: "v0.41.0",
html_url: "https://example.test/v0.41.0",
body: "Latest notes",
},
{
tag_name: "v0.40.1",
html_url: "https://example.test/v0.40.1",
body: "Patch notes",
},
{
tag_name: "v0.40.0",
html_url: "https://example.test/v0.40.0",
body: "First notes",
},
{ tag_name: "v0.40.0-beta.1", prerelease: true },
{ tag_name: "v0.39.0", body: "Current notes" },
],
};
},
});

assert.match(request.url, /releases\?per_page=100&page=1$/);
assert.equal(request.options.headers.Authorization, "Bearer test-token");
assert.deepEqual(
releaseNotes.map(({ version }) => version),
["0.40.0", "0.40.1", "0.41.0"],
);
});

test("release notes fail when the target release is missing", async () => {
await assert.rejects(
fetchPiSubagentsReleaseNotes({
currentVersion: "0.39.0",
targetVersion: "0.40.0",
fetchImpl: async () => ({ ok: true, json: async () => [] }),
}),
/no GitHub release notes found for v0\.40\.0/,
);
});

test("lockfile validation identifies stale installed versions", () => {
const packageLock = lockfile("0.40.0");
const shrinkwrap = lockfile("0.40.0");
Expand All @@ -96,16 +149,45 @@ test("lockfile validation identifies stale installed versions", () => {
);
});

test("PR body renders versions, changed files, and validation", () => {
test("PR body renders versions, release notes, changed files, and validation", () => {
const body = renderPiSubagentsPullRequestBody({
currentVersion: "0.39.0",
targetVersion: "0.40.0",
releaseNotes: [
{
version: "0.40.0",
url: "https://example.test/v0.40.0",
body: "Upstream release details",
},
],
changedFiles: ["package.json", "nix/package.nix"],
validationCommands: ["npm test"],
});

assert.match(body, /from `0\.39\.0` to `0\.40\.0`/);
assert.match(body, /href="https:\/\/example\.test\/v0\.40\.0"/);
assert.match(body, /Upstream release details/);
assert.match(body, /`package\.json`/);
assert.match(body, /`nix\/package\.nix`/);
assert.match(body, /`npm test`/);
});

test("PR body rejects release notes larger than GitHub accepts", () => {
assert.throws(
() =>
renderPiSubagentsPullRequestBody({
currentVersion: "0.39.0",
targetVersion: "0.40.0",
releaseNotes: [
{
version: "0.40.0",
url: "https://example.test/v0.40.0",
body: "x".repeat(65_536),
},
],
changedFiles: ["package.json"],
validationCommands: ["npm test"],
}),
/exceeds GitHub's 65,536-character limit/,
);
});
7 changes: 7 additions & 0 deletions scripts/update-pi-subagents.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
PI_SUBAGENTS_PACKAGE,
assertLockfilesMatchPiSubagentsTarget,
fetchLatestPiSubagentsVersion,
fetchPiSubagentsReleaseNotes,
getCurrentPiSubagentsVersion,
resolvePiSubagentsUpgrade,
} from "./pi-subagents-upgrade-lib.mjs";
Expand Down Expand Up @@ -226,6 +227,7 @@ async function main() {
noUpdate: false,
validateOnly: false,
changedFiles: [],
releaseNotes: [],
validationCommands,
};
let metadataSnapshot;
Expand Down Expand Up @@ -262,6 +264,11 @@ async function main() {
);

if (!resolved.noUpdate) {
summary.releaseNotes = await fetchPiSubagentsReleaseNotes({
currentVersion: resolved.currentVersion,
targetVersion: resolved.targetVersion,
token: process.env.GITHUB_TOKEN,
});
await updatePackageMetadata(packageJson, resolved.targetVersion);
const [updatedPackageJson, updatedPackageLock, updatedShrinkwrap] =
await Promise.all([
Expand Down
Loading