diff --git a/.github/workflows/superpowers-upgrade.yml b/.github/workflows/superpowers-upgrade.yml new file mode 100644 index 00000000..dd87ff99 --- /dev/null +++ b/.github/workflows/superpowers-upgrade.yml @@ -0,0 +1,149 @@ +name: Superpowers dependency upgrade + +on: + schedule: + - cron: "47 6 * * *" + workflow_dispatch: + inputs: + superpowers-version: + description: "Explicit stable Superpowers version" + required: false + type: string + validate-only: + description: + "Resolve and validate without changing files or opening a PR" + required: false + default: false + type: boolean + +permissions: + contents: read + +concurrency: + group: superpowers-dependency-upgrade + cancel-in-progress: false + +jobs: + upgrade: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Install Nix + uses: cachix/install-nix-action@v31 + with: + extra_nix_config: | + experimental-features = nix-command flakes + + - name: Update Superpowers dependency and skill metadata + id: update + env: + GITHUB_TOKEN: ${{ github.token }} + SUPERPOWERS_VERSION: ${{ inputs.superpowers-version }} + VALIDATE_ONLY: ${{ inputs.validate-only || false }} + run: | + set -euo pipefail + mkdir -p .tmp + args=(--summary-json .tmp/superpowers-summary.json) + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + args+=(--mode manual) + [[ -n "${SUPERPOWERS_VERSION:-}" ]] && args+=(--superpowers-version "$SUPERPOWERS_VERSION") + [[ "$VALIDATE_ONLY" == "true" ]] && args+=(--validate-only) + else + args+=(--mode scheduled) + fi + node scripts/update-superpowers.mjs "${args[@]}" + node <<'NODE' >> "$GITHUB_OUTPUT" + const fs = require("node:fs"); + const summary = JSON.parse(fs.readFileSync(".tmp/superpowers-summary.json", "utf8")); + console.log(`no_update=${summary.noUpdate ? "true" : "false"}`); + console.log(`validate_only=${summary.validateOnly ? "true" : "false"}`); + console.log(`target_version=${summary.targetVersion}`); + console.log(`target_tag=${summary.targetTag}`); + NODE + + - name: Run Pi compatibility contract + if: steps.update.outputs.no_update != 'true' + run: node --test src/cli/commands/init/pi-dependency-contract.test.ts + + - name: Run Node tests + if: steps.update.outputs.no_update != 'true' + run: npm test + + - name: Run packed artifact smoke test + if: steps.update.outputs.no_update != 'true' + run: node scripts/smoke-packed-artifact.mjs + + - name: Run lint + if: steps.update.outputs.no_update != 'true' + run: npm run lint + + - name: Verify Nix npm dependency hash + if: steps.update.outputs.no_update != 'true' + run: | + cp nix/package.nix .tmp/nix-package.nix.before-hash + scripts/update-npm-deps-hash.sh + if ! cmp --silent .tmp/nix-package.nix.before-hash nix/package.nix; then + echo "::error file=nix/package.nix::npmDepsHash is stale after the Superpowers update." + exit 1 + fi + + - name: Build Nix package + if: steps.update.outputs.no_update != 'true' + run: nix build .#patchmill --print-build-logs + + - name: Render pull request body + if: >- + steps.update.outputs.no_update != 'true' && + steps.update.outputs.validate_only != 'true' + run: | + node -e 'import("./scripts/superpowers-upgrade-lib.mjs").then(({renderSuperpowersPullRequestBody}) => { const fs = require("node:fs"); const summary = JSON.parse(fs.readFileSync(".tmp/superpowers-summary.json", "utf8")); fs.writeFileSync(".tmp/superpowers-pr-body.md", renderSuperpowersPullRequestBody(summary)); })' + + - name: Create automation bot token + id: app-token + if: >- + steps.update.outputs.no_update != 'true' && + steps.update.outputs.validate_only != 'true' + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.RELEASE_PLEASE_BOT_APP_ID }} + private-key: ${{ secrets.RELEASE_PLEASE_BOT_PRIVATE_KEY }} + + - name: Create or update Superpowers upgrade PR + if: >- + steps.update.outputs.no_update != 'true' && + steps.update.outputs.validate_only != 'true' + uses: peter-evans/create-pull-request@v7 + with: + token: ${{ steps.app-token.outputs.token }} + add-paths: | + package.json + package-lock.json + npm-shrinkwrap.json + nix/package.nix + src/workflow/skill-pack.ts + THIRD_PARTY_NOTICES.md + .patchmill/skills + branch: automation/superpowers-${{ steps.update.outputs.target_tag }} + delete-branch: true + title: >- + chore(deps): update Superpowers to ${{ + steps.update.outputs.target_tag }} + body-path: .tmp/superpowers-pr-body.md + commit-message: >- + chore(deps): update Superpowers to ${{ + steps.update.outputs.target_tag }} + labels: dependencies, automated-pr diff --git a/docs/pi-dependency-upgrades.md b/docs/pi-dependency-upgrades.md index 39165119..0cb05e2b 100644 --- a/docs/pi-dependency-upgrades.md +++ b/docs/pi-dependency-upgrades.md @@ -1,11 +1,13 @@ -# Pi Dependency Upgrades +# Automated Dependency Upgrades + +## Pi runtime upgrades Patchmill keeps `@earendil-works/pi-coding-agent` and `@earendil-works/pi-tui` on exact pins. The `Pi dependency upgrade` workflow discovers matching newer npm `latest` versions on a schedule and opens a review-gated PR after compatibility, packed-artifact, npm, and Nix validation pass. -## Manual validation +### Manual validation ```bash node scripts/update-pi-deps.mjs \ @@ -18,11 +20,36 @@ node scripts/update-pi-deps.mjs \ Omit `--skip-nix-hash` when preparing real dependency changes. +## Superpowers upgrades + +The `Superpowers dependency upgrade` workflow runs daily and opens a dedicated +review-gated pull request when `obra/superpowers` publishes a newer stable +GitHub Release. It does not combine Superpowers and Pi runtime changes. + +The pull request includes the upstream release body for every stable release +after the current pin through the target, ordered from oldest to newest. Missing +or empty release notes prevent pull-request creation. + +Run a non-mutating local validation with: + +```bash +node scripts/update-superpowers.mjs \ + --mode manual \ + --superpowers-version 6.0.3 \ + --validate-only \ + --skip-nix-hash \ + --summary-json .tmp/superpowers-summary.json +``` + +Omit `--superpowers-version` to validate discovery of the latest stable release. +Omit `--validate-only` and `--skip-nix-hash` only when intentionally preparing a +real Superpowers upgrade. + ## Repository automation credentials Configure the repository secrets `RELEASE_PLEASE_BOT_APP_ID` and `RELEASE_PLEASE_BOT_PRIVATE_KEY` for a GitHub App that can create branches and -pull requests. The workflow mints a short-lived installation token only after +pull requests. Both workflows mint a short-lived installation token only after all upgrade validations pass, so the resulting review-gated PR can trigger normal pull request checks; GitHub Actions' default `GITHUB_TOKEN` does not trigger those workflows. Checkout does not persist credentials while dependency @@ -39,4 +66,4 @@ scripts/update-npm-deps-hash.sh nix build .#patchmill --print-build-logs ``` -The workflow does not auto-merge or publish Pi dependency upgrades. +Neither workflow auto-merges or publishes dependency upgrades. diff --git a/docs/plans/2026-08-03-superpowers-auto-upgrade.md b/docs/plans/2026-08-03-superpowers-auto-upgrade.md new file mode 100644 index 00000000..ad8c3353 --- /dev/null +++ b/docs/plans/2026-08-03-superpowers-auto-upgrade.md @@ -0,0 +1,1330 @@ +# Superpowers Auto-Upgrade Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task-by-task. Steps use +> checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an independent daily workflow that opens a review-gated +Superpowers upgrade pull request containing every intervening upstream release +note and fully synchronized package, skill-pack, and Nix metadata. + +**Architecture:** Keep the existing Pi updater unchanged. Add a focused +Superpowers release library, a filesystem transaction helper, and a dedicated +updater CLI; the GitHub workflow orchestrates the updater, existing validation +commands, and App-token pull-request creation. Treat `package.json` as the +canonical installed pin and verify every generated reference against it. + +**Tech Stack:** Node.js 24 ESM, TypeScript tests through Node's test runner, npm +lockfiles, GitHub REST Releases API, GitHub Actions, Patchmill's existing skill +installer, and Nix `buildNpmPackage`. + +**Design:** `docs/specs/2026-08-03-superpowers-auto-upgrade-design.md` + +## Global Constraints + +- Keep `.github/workflows/pi-dependency-upgrade.yml`, + `scripts/update-pi-deps.mjs`, and their existing behavior unchanged. +- Create a dedicated daily Superpowers workflow and a separate + `automation/superpowers-vX.Y.Z` pull request. +- Do not upgrade the current `v6.0.3` Superpowers pin in the automation + implementation commit; the new workflow must propose that upgrade separately. +- Use only stable `obra/superpowers` GitHub Releases; exclude drafts and + prereleases. +- Embed every non-empty upstream release body after the current pin through the + target, ordered from oldest to newest. +- Fail before pull-request creation when release notes, package metadata, + configured skills, project-local metadata, tests, packaging, or Nix checks are + incomplete. +- Preserve the canonical dependency form + `https://github.com/obra/superpowers/archive/refs/tags/vX.Y.Z.tar.gz`. +- Keep pull requests review-gated; do not auto-merge or publish. +- Do not add a test that merely asserts workflow YAML text. Verify workflow YAML + with formatting and `actionlint` instead. +- Use the existing `repairMissingLockfileIntegrities` helper and + `scripts/update-npm-deps-hash.sh`; add no npm dependency. + +--- + +### Task 1: Superpowers release discovery and PR rendering + +**Files:** + +- Create: `scripts/superpowers-upgrade-lib.mjs` +- Create: `scripts/superpowers-upgrade-lib.test.mjs` + +**Interfaces:** + +- Produces: + - `SUPERPOWERS_PACKAGE: "superpowers"` + - `SUPERPOWERS_REPOSITORY: "obra/superpowers"` + - `normalizeVersion(value, label?): string` + - `tagForVersion(version): string` + - `tarballUrlForVersion(version): string` + - `getCurrentSuperpowersVersion(packageJson): string` + - `compareVersions(a, b): number` + - `normalizeGitHubRelease(release): Release | undefined` + - `fetchStableReleases({ fetchImpl, token, repository? }): Promise` + - `fetchReleasePackageVersion({ fetchImpl, token, repository?, tag }): Promise` + - `resolveSuperpowersUpgrade({ currentVersion, requestedVersion?, releases }): UpgradeResolution` + - `assertLockfilesMatchSuperpowersTarget({ packageJson, packageLock, shrinkwrap, targetVersion }): void` + - `renderSuperpowersPullRequestBody(summary): string` +- `Release` has `{ tag, version, name, htmlUrl, publishedAt, body }` string + fields. +- `UpgradeResolution` has + `{ noUpdate, currentVersion, targetVersion, targetTag, releases }`, where + `releases` is the ascending in-range release list. +- Consumed by Tasks 3, 4, and 5. + +- [ ] **Step 1: Write failing version and dependency-spec tests** + +Add table-driven tests that establish strict stable versions and the canonical +GitHub tarball URL: + +```js +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + compareVersions, + getCurrentSuperpowersVersion, + normalizeVersion, + tagForVersion, + tarballUrlForVersion, +} from "./superpowers-upgrade-lib.mjs"; + +const currentSpec = + "https://github.com/obra/superpowers/archive/refs/tags/v6.0.3.tar.gz"; + +test("normalizes stable versions and constructs canonical references", () => { + assert.equal(normalizeVersion("v6.2.0"), "6.2.0"); + assert.equal(tagForVersion("6.2.0"), "v6.2.0"); + assert.equal( + tarballUrlForVersion("6.2.0"), + "https://github.com/obra/superpowers/archive/refs/tags/v6.2.0.tar.gz", + ); + assert.equal(compareVersions("6.1.1", "6.2.0"), -1); +}); + +test("rejects prerelease and malformed versions", () => { + for (const value of ["6.2", "6.2.0-beta.1", "release-6.2.0", "06.2.0"]) { + assert.throws(() => normalizeVersion(value), /stable X\.Y\.Z version/); + } +}); + +test("reads the current version only from the canonical dependency URL", () => { + assert.equal( + getCurrentSuperpowersVersion({ + dependencies: { superpowers: currentSpec }, + }), + "6.0.3", + ); + assert.throws( + () => + getCurrentSuperpowersVersion({ + dependencies: { superpowers: "github:obra/superpowers#v6.0.3" }, + }), + /canonical GitHub tag tarball/, + ); +}); +``` + +- [ ] **Step 2: Run the version tests to verify RED** + +Run: + +```bash +node --test scripts/superpowers-upgrade-lib.test.mjs +``` + +Expected: FAIL with `ERR_MODULE_NOT_FOUND` for +`scripts/superpowers-upgrade-lib.mjs`. + +- [ ] **Step 3: Implement strict version and package-spec helpers** + +Create the library constants and helpers with these exact validation rules: + +```js +export const SUPERPOWERS_PACKAGE = "superpowers"; +export const SUPERPOWERS_REPOSITORY = "obra/superpowers"; + +const stableVersionPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/u; +const canonicalSpecPattern = + /^https:\/\/github\.com\/obra\/superpowers\/archive\/refs\/tags\/v((?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*))\.tar\.gz$/u; + +export function normalizeVersion(value, label = "Superpowers version") { + const normalized = value.startsWith("v") ? value.slice(1) : value; + if (!stableVersionPattern.test(normalized)) { + throw new Error(`${label} must be a stable X.Y.Z version; found ${value}`); + } + return normalized; +} + +export function tagForVersion(version) { + return `v${normalizeVersion(version)}`; +} + +export function tarballUrlForVersion(version) { + return `https://github.com/obra/superpowers/archive/refs/tags/${tagForVersion(version)}.tar.gz`; +} + +export function compareVersions(a, b) { + const left = normalizeVersion(a).split(".").map(Number); + const right = normalizeVersion(b).split(".").map(Number); + for (let index = 0; index < left.length; index += 1) { + if (left[index] !== right[index]) return left[index] - right[index]; + } + return 0; +} + +export function getCurrentSuperpowersVersion(packageJson) { + const spec = packageJson.dependencies?.[SUPERPOWERS_PACKAGE]; + const match = canonicalSpecPattern.exec(spec ?? ""); + if (!match) { + throw new Error( + `${SUPERPOWERS_PACKAGE} must use the canonical GitHub tag tarball; found ${spec ?? "missing"}`, + ); + } + return normalizeVersion(match[1]); +} +``` + +- [ ] **Step 4: Run the version tests to verify GREEN** + +Run: + +```bash +node --test scripts/superpowers-upgrade-lib.test.mjs +``` + +Expected: PASS for the three version/spec tests. + +- [ ] **Step 5: Add failing paginated release-discovery and range tests** + +Use dependency-injected `fetchImpl` responses. Include two API pages so the test +proves pagination, and include drafts and prereleases so the test proves they +are excluded: + +```js +function githubResponse(body, { link, status = 200 } = {}) { + return new Response(JSON.stringify(body), { + status, + headers: { + "content-type": "application/json", + ...(link ? { link } : {}), + }, + }); +} + +function release(tag, overrides = {}) { + return { + tag_name: tag, + name: tag, + html_url: `https://github.com/obra/superpowers/releases/tag/${tag}`, + published_at: `2026-07-${tag === "v6.2.0" ? "24" : "20"}T00:00:00Z`, + body: `Notes for ${tag}`, + draft: false, + prerelease: false, + ...overrides, + }; +} + +test("fetches every stable release page and selects the full upgrade range", async () => { + const requests = []; + const fetchImpl = async (url, options) => { + requests.push({ url: String(url), options }); + if (String(url).endsWith("page=1")) { + return githubResponse( + [release("v6.2.0"), release("v6.2.0-beta.1", { prerelease: true })], + { + link: '; rel="next"', + }, + ); + } + return githubResponse([ + release("v6.1.1"), + release("v6.1.0"), + release("v6.0.3"), + release("v6.0.2", { draft: true }), + ]); + }; + + const releases = await fetchStableReleases({ fetchImpl, token: "token" }); + const result = resolveSuperpowersUpgrade({ + currentVersion: "6.0.3", + releases, + }); + + assert.equal(requests.length, 2); + assert.equal(requests[0].options.headers.authorization, "Bearer token"); + assert.deepEqual( + result.releases.map(({ tag }) => tag), + ["v6.1.0", "v6.1.1", "v6.2.0"], + ); + assert.equal(result.targetVersion, "6.2.0"); + assert.equal(result.noUpdate, false); +}); + +test("requires a stable release and a non-empty body for every selected version", () => { + assert.throws( + () => + resolveSuperpowersUpgrade({ + currentVersion: "6.0.3", + requestedVersion: "6.2.0", + releases: [ + normalizeGitHubRelease(release("v6.2.0")), + normalizeGitHubRelease(release("v6.1.0", { body: " " })), + ], + }), + /v6\.1\.0 has no release-note body/, + ); +}); + +test("returns no-update for an equal target", () => { + const result = resolveSuperpowersUpgrade({ + currentVersion: "6.0.3", + requestedVersion: "6.0.3", + releases: [normalizeGitHubRelease(release("v6.0.3"))], + }); + assert.equal(result.noUpdate, true); + assert.deepEqual(result.releases, []); +}); +``` + +Import `fetchStableReleases`, `normalizeGitHubRelease`, and +`resolveSuperpowersUpgrade` in the test. + +- [ ] **Step 6: Run the release tests to verify RED** + +Run: + +```bash +node --test scripts/superpowers-upgrade-lib.test.mjs +``` + +Expected: FAIL because the release functions are not exported. + +- [ ] **Step 7: Implement authenticated pagination and range resolution** + +Implement GitHub requests with `per_page=100`, follow the RFC 5988 `rel="next"` +link until absent, normalize stable releases, sort by semantic version, require +the target release, then select versions satisfying +`current < release <= target`. Validate non-empty bodies only for selected +releases so historical notes below the current pin cannot block an upgrade. + +Use headers that are stable in GitHub Actions: + +```js +function githubHeaders(token) { + return { + accept: "application/vnd.github+json", + "x-github-api-version": "2022-11-28", + ...(token ? { authorization: `Bearer ${token}` } : {}), + }; +} +``` + +When a request fails, include repository, endpoint, status, and status text in +the thrown error. Reject a requested target absent from the stable release list. + +- [ ] **Step 8: Add failing release-package and PR-body tests** + +Test the GitHub contents response and exact release-note ordering: + +```js +test("verifies that the release package version matches its tag", async () => { + const fetchImpl = async () => + githubResponse({ + encoding: "base64", + content: Buffer.from(JSON.stringify({ version: "6.2.0" })).toString( + "base64", + ), + }); + assert.equal( + await fetchReleasePackageVersion({ + fetchImpl, + token: "token", + tag: "v6.2.0", + }), + "6.2.0", + ); +}); + +test("renders every release body oldest to newest", () => { + const body = renderSuperpowersPullRequestBody({ + currentVersion: "6.0.3", + targetVersion: "6.2.0", + changedFiles: ["package.json", ".patchmill/skills/writing-plans/SKILL.md"], + validationCommands: [ + "npm test", + "nix build .#patchmill --print-build-logs", + ], + releases: [ + normalizeGitHubRelease(release("v6.1.0")), + normalizeGitHubRelease(release("v6.1.1")), + normalizeGitHubRelease(release("v6.2.0")), + ], + }); + assert.ok(body.indexOf("### v6.1.0") < body.indexOf("### v6.1.1")); + assert.ok(body.indexOf("### v6.1.1") < body.indexOf("### v6.2.0")); + assert.match(body, /Notes for v6\.1\.0/); + assert.match(body, /does not auto-merge or publish/); +}); +``` + +Also assert that rendering rejects a body longer than GitHub's 65,536-character +limit instead of truncating upstream notes. + +- [ ] **Step 9: Implement package-version fetching and PR rendering** + +Fetch `package.json` through +`/repos/obra/superpowers/contents/package.json?ref=vX.Y.Z`, decode base64, parse +its `version`, and require it to equal the normalized tag. Render release bodies +verbatim into a Markdown string with current/target versions, changed files, +validation commands, publication dates, and release links. Check +`body.length <= 65_536` before returning. + +- [ ] **Step 10: Add failing lockfile consistency tests** + +Create valid `package.json`, `package-lock.json`, and `npm-shrinkwrap.json` +fixtures containing the canonical target spec, `version`, `resolved`, and +`integrity`. Mutate each field one at a time and assert errors identify the +file, expected value, and actual value. + +- [ ] **Step 11: Implement lockfile consistency validation** + +For both lockfiles, validate: + +```js +const expectedSpec = tarballUrlForVersion(targetVersion); +const rootSpec = lockfile.packages?.[""]?.dependencies?.superpowers; +const installed = lockfile.packages?.["node_modules/superpowers"]; +``` + +Require `rootSpec === expectedSpec`, `installed.version === targetVersion`, +`installed.resolved === expectedSpec`, and a non-empty `installed.integrity`. +Apply the same package dependency check to `package.json`. + +- [ ] **Step 12: Run the complete library test file** + +Run: + +```bash +node --test scripts/superpowers-upgrade-lib.test.mjs +``` + +Expected: all release, package, rendering, and lockfile tests PASS. + +- [ ] **Step 13: Commit Task 1** + +```bash +git add scripts/superpowers-upgrade-lib.mjs \ + scripts/superpowers-upgrade-lib.test.mjs +git commit -m "feat(deps): add Superpowers release selection" +``` + +--- + +### Task 2: Transactional tracked-file snapshots + +**Files:** + +- Create: `scripts/tracked-files.mjs` +- Create: `scripts/tracked-files.test.mjs` + +**Interfaces:** + +- Produces: + - `snapshotTrackedPaths(rootDir, relativeRoots): Promise` + - `changedTrackedFiles(before, after): string[]` + - `restoreTrackedPaths(rootDir, relativeRoots, snapshot): Promise` +- `Snapshot` is a `Map` keyed by + POSIX-style repository-relative file paths. +- Consumed by Task 3 for rollback and Git-independent changed-file reporting. + +- [ ] **Step 1: Write failing snapshot, diff, and restoration tests** + +Use a temporary root containing individual metadata files and a nested +`.patchmill/skills` tree. Test modification, addition, deletion, executable +mode, and complete rollback: + +```js +import assert from "node:assert/strict"; +import { + access, + chmod, + mkdtemp, + mkdir, + readFile, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { + changedTrackedFiles, + restoreTrackedPaths, + snapshotTrackedPaths, +} from "./tracked-files.mjs"; + +test("reports and restores nested tracked file changes without Git", async () => { + const root = await mkdtemp(join(tmpdir(), "patchmill-tracked-files-")); + const roots = ["package.json", ".patchmill/skills"]; + try { + await mkdir(join(root, ".patchmill/skills/writing-plans"), { + recursive: true, + }); + await writeFile(join(root, "package.json"), "old package\n"); + const skill = join(root, ".patchmill/skills/writing-plans/SKILL.md"); + await writeFile(skill, "old skill\n"); + await chmod(skill, 0o755); + const before = await snapshotTrackedPaths(root, roots); + + await writeFile(join(root, "package.json"), "new package\n"); + await rm(skill); + await writeFile( + join(root, ".patchmill/skills/writing-plans/reference.md"), + "new reference\n", + ); + const after = await snapshotTrackedPaths(root, roots); + + assert.deepEqual(changedTrackedFiles(before, after), [ + ".patchmill/skills/writing-plans/SKILL.md", + ".patchmill/skills/writing-plans/reference.md", + "package.json", + ]); + + await restoreTrackedPaths(root, roots, before); + assert.equal( + await readFile(join(root, "package.json"), "utf8"), + "old package\n", + ); + assert.equal(await readFile(skill, "utf8"), "old skill\n"); + assert.equal((await stat(skill)).mode & 0o777, 0o755); + await assert.rejects( + access(join(root, ".patchmill/skills/writing-plans/reference.md")), + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); +``` + +Add a second test proving an initially absent tracked root is removed during +rollback. + +- [ ] **Step 2: Run the tracked-file tests to verify RED** + +Run: + +```bash +node --test scripts/tracked-files.test.mjs +``` + +Expected: FAIL with `ERR_MODULE_NOT_FOUND`. + +- [ ] **Step 3: Implement recursive snapshots and restoration** + +Use `lstat` and sorted `readdir({ withFileTypes: true })`. Record regular file +bytes and permission mode, normalize keys with `/`, and reject symbolic links or +special files with an error naming the path. Missing roots contribute no map +entries. + +`restoreTrackedPaths` must remove every tracked root recursively before +recreating snapshot directories/files. `changedTrackedFiles` compares the union +of keys and reports changed content or mode in lexical order. + +- [ ] **Step 4: Run the tracked-file tests to verify GREEN** + +Run: + +```bash +node --test scripts/tracked-files.test.mjs +``` + +Expected: all snapshot and restoration tests PASS. + +- [ ] **Step 5: Commit Task 2** + +```bash +git add scripts/tracked-files.mjs scripts/tracked-files.test.mjs +git commit -m "feat(deps): add tracked file transactions" +``` + +--- + +### Task 3: Dedicated Superpowers updater CLI + +**Files:** + +- Create: `scripts/update-superpowers.mjs` +- Create: `scripts/update-superpowers.test.mjs` +- Modify: `scripts/superpowers-upgrade-lib.mjs` +- Modify: `scripts/superpowers-upgrade-lib.test.mjs` + +**Interfaces:** + +- Consumes all Task 1 release/lock helpers and Task 2 transaction helpers. +- Produces: + - CLI flags `--mode scheduled|manual`, `--superpowers-version X.Y.Z`, + `--validate-only`, `--skip-nix-hash`, and `--summary-json PATH`. + - `runSuperpowersUpgrade(args, dependencies?): Promise` for tests. + - Summary JSON fields + `{ currentVersion, targetVersion, targetTag, noUpdate, validateOnly, releases, changedFiles, validationCommands, error? }`. +- The production entry point invokes + `runSuperpowersUpgrade(process.argv.slice(2))` and exits nonzero after writing + failure summary JSON. +- Consumed by Task 5's workflow. + +- [ ] **Step 1: Write failing argument and validate-only tests** + +Test the exported runner with a temporary repository fixture and injected +`fetchImpl`/`runCommand`. The fixture must not contain `.git`, proving the +updater does not rely on Git. + +Define deterministic test helpers before the cases: + +```js +const fixtureTrackedRoots = [ + "package.json", + "package-lock.json", + "npm-shrinkwrap.json", + "nix/package.nix", + "src/workflow/skill-pack.ts", + "THIRD_PARTY_NOTICES.md", + ".patchmill/skills", +]; + +async function snapshotFixture(rootDir) { + const snapshot = await snapshotTrackedPaths(rootDir, fixtureTrackedRoots); + return [...snapshot].map(([path, entry]) => [ + path, + entry.content.toString("base64"), + entry.mode, + ]); +} + +function release(tag) { + return { + tag_name: tag, + name: tag, + html_url: `https://github.com/obra/superpowers/releases/tag/${tag}`, + published_at: "2026-07-24T00:00:00Z", + body: `Notes for ${tag}`, + draft: false, + prerelease: false, + }; +} + +async function releaseFetch(url) { + if (String(url).includes("/contents/package.json")) { + return new Response( + JSON.stringify({ + encoding: "base64", + content: Buffer.from(JSON.stringify({ version: "6.2.0" })).toString( + "base64", + ), + }), + { status: 200 }, + ); + } + return new Response( + JSON.stringify([ + release("v6.2.0"), + release("v6.1.1"), + release("v6.1.0"), + release("v6.0.3"), + ]), + { status: 200 }, + ); +} +``` + +Import `snapshotTrackedPaths` from Task 2. Cover these cases: + +```js +test("validate-only resolves all release notes without mutating files", async () => { + const before = await snapshotFixture(rootDir); + const summary = await runSuperpowersUpgrade( + ["--mode", "manual", "--superpowers-version", "6.2.0", "--validate-only"], + { + rootDir, + fetchImpl: releaseFetch, + runCommand: async () => + assert.fail("validate-only must not run commands"), + }, + ); + assert.equal(summary.currentVersion, "6.0.3"); + assert.equal(summary.targetVersion, "6.2.0"); + assert.deepEqual( + summary.releases.map(({ tag }) => tag), + ["v6.1.0", "v6.1.1", "v6.2.0"], + ); + assert.deepEqual(summary.changedFiles, []); + assert.deepEqual(await snapshotFixture(rootDir), before); +}); + +test("records argument failures in summary JSON", async () => { + await assert.rejects( + runSuperpowersUpgrade( + ["--mode", "invalid", "--summary-json", summaryPath], + { rootDir, fetchImpl: releaseFetch }, + ), + /--mode must be scheduled or manual/, + ); + const summary = JSON.parse(await readFile(summaryPath, "utf8")); + assert.match(summary.error, /--mode must be scheduled or manual/); + assert.deepEqual(summary.changedFiles, []); +}); +``` + +Also test scheduled latest selection and equal-version no-update behavior. + +- [ ] **Step 2: Run updater tests to verify RED** + +Run: + +```bash +node --test scripts/update-superpowers.test.mjs +``` + +Expected: FAIL with `ERR_MODULE_NOT_FOUND` for `update-superpowers.mjs`. + +- [ ] **Step 3: Implement CLI parsing, release resolution, and summaries** + +Use the same `summaryPathFromArgs` safety pattern as `update-pi-deps.mjs`, so +parse errors can still be recorded. Manual mode accepts an omitted explicit +version and then selects latest stable, matching the workflow's optional input. + +Define validation commands exactly once in the summary: + +```js +const validationCommands = [ + "npm ci", + "node bin/patchmill.ts skills update", + "node --test src/cli/commands/init/pi-dependency-contract.test.ts", + "npm test", + "node scripts/smoke-packed-artifact.mjs", + "npm run lint", + "scripts/update-npm-deps-hash.sh", + "nix build .#patchmill --print-build-logs", +]; +``` + +Fetch releases and the target tag's `package.json`; require its package version +to equal `targetVersion`. Add an internal +`assertSynchronizedRepository(rootDir, expectedVersion)` helper that reads both +lockfiles, `src/workflow/skill-pack.ts`, `THIRD_PARTY_NOTICES.md`, and +`.patchmill/skills/patchmill-skill-pack.json`. It must call +`assertLockfilesMatchSuperpowersTarget`, require exactly two expected tags in +the live skill-pack source, exactly one expected tagged notice URL, and matching +project-local source metadata. In `validate-only`, call this helper with +`currentVersion`, verify every configured upstream `SKILL.md` exists under the +installed `superpowers/skills` directory, collect target release notes, write +the summary, and do not mutate or invoke commands. + +- [ ] **Step 4: Add failing metadata mutation tests** + +Build a fixture with the real shapes of: + +- `package.json`; +- both npm lockfiles; +- `src/workflow/skill-pack.ts` containing exactly two `v6.0.3` references; +- `THIRD_PARTY_NOTICES.md` containing exactly one tagged source reference; +- `.patchmill/skills/patchmill-skill-pack.json`. + +Inject a `runCommand` fake that records commands and simulates npm lockfile +output and skill-pack metadata generation. Assert a successful upgrade: + +- writes the canonical `v6.2.0` package URL; +- updates exactly the expected skill-pack and notice references; +- invokes lockfile generation, `npm ci`, `patchmill skills update`, and the Nix + hash updater in order; +- validates both resulting lockfiles; +- lists nested `.patchmill/skills` changes without Git. + +- [ ] **Step 5: Implement transactional metadata updates** + +Track these roots: + +```js +const trackedRoots = [ + "package.json", + "package-lock.json", + "npm-shrinkwrap.json", + "nix/package.nix", + "src/workflow/skill-pack.ts", + "THIRD_PARTY_NOTICES.md", + ".patchmill/skills", +]; +``` + +Apply mutations in this order: + +1. Snapshot all tracked roots. +2. Write the canonical target tarball to `package.json`. +3. Replace exactly two current tags in `src/workflow/skill-pack.ts`; fail if the + match count differs. +4. Replace exactly one current tag in `THIRD_PARTY_NOTICES.md`; fail if the + match count differs. +5. Regenerate both lockfiles using the same two-pass shrinkwrap/package-lock + sequence used by `update-pi-deps.mjs`. +6. Call `repairMissingLockfileIntegrities` for both lockfiles and write them. +7. Call `assertLockfilesMatchSuperpowersTarget`. +8. Run `npm ci`. +9. Run `node bin/patchmill.ts skills update`. +10. Run `scripts/update-npm-deps-hash.sh` unless `--skip-nix-hash` is set. +11. Snapshot again and populate sorted `changedFiles`. + +Use `spawn` with `cwd: rootDir` and inherited stdio for the production +`runCommand`. Never build shell command strings; pass executable and argument +arrays. + +- [ ] **Step 6: Add failing rollback and synchronization tests** + +Make the injected `runCommand` fail at each mutation-stage command. For every +failure assert: + +- the thrown error names the failed executable and arguments; +- all tracked files and modes equal the initial snapshot; +- the summary JSON contains the primary error and an empty changed-file list. + +Add failures for unexpected source pin match counts and lockfile target +mismatch. + +- [ ] **Step 7: Implement rollback and post-update consistency checks** + +Wrap all mutation after the initial snapshot in `try/catch`. On error, call +`restoreTrackedPaths(rootDir, trackedRoots, before)`, write the failure summary, +and rethrow the primary error. If restoration fails, append the restoration +error to stderr without replacing the primary summary error. + +After `patchmill skills update`, read its metadata and require its source tag +and tarball URL to match the target before taking the final snapshot. + +- [ ] **Step 8: Run updater and library tests** + +Run: + +```bash +node --test \ + scripts/superpowers-upgrade-lib.test.mjs \ + scripts/tracked-files.test.mjs \ + scripts/update-superpowers.test.mjs +``` + +Expected: all tests PASS, including rollback and Git-independent changed-file +reporting. + +- [ ] **Step 9: Run a real non-mutating current-pin validation** + +Run: + +```bash +rm -rf .tmp/superpowers-validation +node scripts/update-superpowers.mjs \ + --mode manual \ + --superpowers-version 6.0.3 \ + --validate-only \ + --skip-nix-hash \ + --summary-json .tmp/superpowers-validation/summary.json +git diff --exit-code +``` + +Expected: exit 0, summary reports `currentVersion` and `targetVersion` as +`6.0.3`, `noUpdate: true`, and Git reports no tracked changes. + +- [ ] **Step 10: Commit Task 3** + +```bash +git add scripts/update-superpowers.mjs \ + scripts/update-superpowers.test.mjs \ + scripts/superpowers-upgrade-lib.mjs \ + scripts/superpowers-upgrade-lib.test.mjs +git commit -m "feat(deps): add Superpowers upgrade updater" +``` + +--- + +### Task 4: Repository-wide Superpowers consistency contract + +**Files:** + +- Create: `scripts/superpowers-repository-contract.test.mjs` +- Modify: `src/workflow/skill-pack.test.ts:20-75` +- Modify: `src/workflow/skill-pack.test.ts:150-165` + +**Interfaces:** + +- Consumes Task 1's canonical version/spec helpers and the exported + `PATCHMILL_RECOMMENDED_SKILL_PACK` configuration. +- Proves the package pin, lockfiles, notice, live skill-pack source, checked-in + project-local metadata, file hashes, and installed upstream paths all agree. +- Runs automatically through the existing `scripts/*.test.mjs` npm test glob. + +- [ ] **Step 1: Replace fixed skill-pack source expectations with a failing + dynamic expectation** + +In `src/workflow/skill-pack.test.ts`, parse the expected version from +`package.json` instead of repeating `v6.0.3`: + +```ts +const packageJson = JSON.parse( + readFileSync(join(repoRoot, "package.json"), "utf8"), +) as { dependencies?: Record }; +const superpowersSpec = packageJson.dependencies?.superpowers ?? ""; +const superpowersMatch = + /^https:\/\/github\.com\/obra\/superpowers\/archive\/refs\/tags\/(v\d+\.\d+\.\d+)\.tar\.gz$/u.exec( + superpowersSpec, + ); +assert.ok(superpowersMatch, "package.json must pin a stable Superpowers tag"); +const expectedSuperpowersSource = { + type: "github-release" as const, + repository: "obra/superpowers", + tag: superpowersMatch[1], + tarballUrl: superpowersSpec, +}; +``` + +Use `expectedSuperpowersSource` in both the default-pack and +`buildSkillPackMetadata` assertions. This test must fail if only one side of a +future upgrade changes. + +- [ ] **Step 2: Run the focused skill-pack test** + +Run: + +```bash +node --test src/workflow/skill-pack.test.ts +``` + +Expected: PASS with the current synchronized `v6.0.3` repository. + +- [ ] **Step 3: Write the repository consistency contract** + +Create a test that: + +1. Reads `package.json`, both lockfiles, `THIRD_PARTY_NOTICES.md`, and + `.patchmill/skills/patchmill-skill-pack.json`. +2. Gets the current version through `getCurrentSuperpowersVersion`. +3. Calls `assertLockfilesMatchSuperpowersTarget`. +4. Imports `PATCHMILL_RECOMMENDED_SKILL_PACK` and compares its source to the + canonical tag and tarball. +5. Requires the notice to contain exactly + `https://github.com/obra/superpowers/tree/vX.Y.Z/skills`. +6. Compares checked-in metadata source to the live pack source. +7. Resolves `superpowers/package.json` with `createRequire`, confirms its + package version, and checks every configured Superpowers skill has `SKILL.md` + under the resolved `skills` directory. +8. Recomputes SHA-256 for every file listed in checked-in metadata and compares + it to `sha256`. + +Use a subtest per configured skill so failures name the missing upstream path: + +```js +for (const skill of PATCHMILL_RECOMMENDED_SKILL_PACK.skills.filter( + ({ source }) => source === "superpowers", +)) { + await t.test(`installed upstream skill exists: ${skill.name}`, async () => { + await access(join(superpowersRoot, "skills", skill.name, "SKILL.md")); + }); +} +``` + +- [ ] **Step 4: Run consistency and skill-pack tests** + +Run: + +```bash +node --test \ + scripts/superpowers-repository-contract.test.mjs \ + src/workflow/skill-pack.test.ts +``` + +Expected: all consistency, metadata-hash, and upstream-path tests PASS. + +- [ ] **Step 5: Commit Task 4** + +```bash +git add scripts/superpowers-repository-contract.test.mjs \ + src/workflow/skill-pack.test.ts +git commit -m "test(skills): enforce Superpowers pin consistency" +``` + +--- + +### Task 5: Dedicated GitHub Actions workflow + +**Files:** + +- Create: `.github/workflows/superpowers-upgrade.yml` + +**Interfaces:** + +- Consumes Task 3's updater flags and summary schema. +- Consumes Task 1's `renderSuperpowersPullRequestBody(summary)` export. +- Produces daily and manual GitHub Actions runs plus review-gated + `automation/superpowers-vX.Y.Z` pull requests. + +- [ ] **Step 1: Create the dedicated workflow** + +Use the Pi workflow's action versions and App-token pattern, but use a separate +schedule, concurrency group, outputs, branch, title, and changed paths: + +```yaml +name: Superpowers dependency upgrade + +on: + schedule: + - cron: "47 6 * * *" + workflow_dispatch: + inputs: + superpowers-version: + description: "Explicit stable Superpowers version" + required: false + type: string + validate-only: + description: + "Resolve and validate without changing files or opening a PR" + required: false + default: false + type: boolean + +permissions: + contents: read + +concurrency: + group: superpowers-dependency-upgrade + cancel-in-progress: false + +jobs: + upgrade: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Install Nix + uses: cachix/install-nix-action@v31 + with: + extra_nix_config: | + experimental-features = nix-command flakes + + - name: Update Superpowers dependency and skill metadata + id: update + env: + GITHUB_TOKEN: ${{ github.token }} + SUPERPOWERS_VERSION: ${{ inputs.superpowers-version }} + VALIDATE_ONLY: ${{ inputs.validate-only || false }} + run: | + set -euo pipefail + mkdir -p .tmp + args=(--summary-json .tmp/superpowers-summary.json) + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + args+=(--mode manual) + [[ -n "${SUPERPOWERS_VERSION:-}" ]] && args+=(--superpowers-version "$SUPERPOWERS_VERSION") + [[ "$VALIDATE_ONLY" == "true" ]] && args+=(--validate-only) + else + args+=(--mode scheduled) + fi + node scripts/update-superpowers.mjs "${args[@]}" + node <<'NODE' >> "$GITHUB_OUTPUT" + const fs = require("node:fs"); + const summary = JSON.parse(fs.readFileSync(".tmp/superpowers-summary.json", "utf8")); + console.log(`no_update=${summary.noUpdate ? "true" : "false"}`); + console.log(`validate_only=${summary.validateOnly ? "true" : "false"}`); + console.log(`target_version=${summary.targetVersion}`); + console.log(`target_tag=${summary.targetTag}`); + NODE + + - name: Run Pi compatibility contract + if: steps.update.outputs.no_update != 'true' + run: node --test src/cli/commands/init/pi-dependency-contract.test.ts + + - name: Run Node tests + if: steps.update.outputs.no_update != 'true' + run: npm test + + - name: Run packed artifact smoke test + if: steps.update.outputs.no_update != 'true' + run: node scripts/smoke-packed-artifact.mjs + + - name: Run lint + if: steps.update.outputs.no_update != 'true' + run: npm run lint + + - name: Verify Nix npm dependency hash + if: steps.update.outputs.no_update != 'true' + run: | + cp nix/package.nix .tmp/nix-package.nix.before-hash + scripts/update-npm-deps-hash.sh + if ! cmp --silent .tmp/nix-package.nix.before-hash nix/package.nix; then + echo "::error file=nix/package.nix::npmDepsHash is stale after the Superpowers update." + exit 1 + fi + + - name: Build Nix package + if: steps.update.outputs.no_update != 'true' + run: nix build .#patchmill --print-build-logs + + - name: Render pull request body + if: >- + steps.update.outputs.no_update != 'true' && + steps.update.outputs.validate_only != 'true' + run: | + node -e 'import("./scripts/superpowers-upgrade-lib.mjs").then(({renderSuperpowersPullRequestBody}) => { const fs = require("node:fs"); const summary = JSON.parse(fs.readFileSync(".tmp/superpowers-summary.json", "utf8")); fs.writeFileSync(".tmp/superpowers-pr-body.md", renderSuperpowersPullRequestBody(summary)); })' + + - name: Create automation bot token + id: app-token + if: >- + steps.update.outputs.no_update != 'true' && + steps.update.outputs.validate_only != 'true' + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.RELEASE_PLEASE_BOT_APP_ID }} + private-key: ${{ secrets.RELEASE_PLEASE_BOT_PRIVATE_KEY }} + + - name: Create or update Superpowers upgrade PR + if: >- + steps.update.outputs.no_update != 'true' && + steps.update.outputs.validate_only != 'true' + uses: peter-evans/create-pull-request@v7 + with: + token: ${{ steps.app-token.outputs.token }} + add-paths: | + package.json + package-lock.json + npm-shrinkwrap.json + nix/package.nix + src/workflow/skill-pack.ts + THIRD_PARTY_NOTICES.md + .patchmill/skills + branch: automation/superpowers-${{ steps.update.outputs.target_tag }} + delete-branch: true + title: >- + chore(deps): update Superpowers to ${{ + steps.update.outputs.target_tag }} + body-path: .tmp/superpowers-pr-body.md + commit-message: >- + chore(deps): update Superpowers to ${{ + steps.update.outputs.target_tag }} + labels: dependencies, automated-pr +``` + +- [ ] **Step 2: Format and validate workflow syntax directly** + +Run: + +```bash +npx prettier --check .github/workflows/superpowers-upgrade.yml +nix run nixpkgs#actionlint -- .github/workflows/superpowers-upgrade.yml +``` + +Expected: both commands exit 0; `actionlint` prints no diagnostics. This is +direct verification, not a test that restates YAML content. + +- [ ] **Step 3: Exercise the workflow's updater contract locally** + +Run the updater in validate-only latest mode and inspect only stable summary +fields: + +```bash +rm -rf .tmp/superpowers-workflow-check +node scripts/update-superpowers.mjs \ + --mode scheduled \ + --validate-only \ + --skip-nix-hash \ + --summary-json .tmp/superpowers-workflow-check/summary.json +node -e 'const s=require("./.tmp/superpowers-workflow-check/summary.json"); if (!s.targetTag || !Array.isArray(s.releases) || s.changedFiles.length) process.exit(1)' +git diff --exit-code +``` + +Expected: exit 0, a stable `targetTag`, complete release array, no changed +files, and no Git diff. + +- [ ] **Step 4: Commit Task 5** + +```bash +git add .github/workflows/superpowers-upgrade.yml +git commit -m "ci(deps): automate Superpowers upgrade PRs" +``` + +--- + +### Task 6: Documentation and full verification + +**Files:** + +- Modify: `docs/pi-dependency-upgrades.md:1-43` + +**Interfaces:** + +- Documents independent Pi and Superpowers schedules, local/manual commands, + release-note behavior, credentials, validation, and review policy. +- Does not rename the existing documentation path, avoiding broken links. + +- [ ] **Step 1: Expand the dependency-upgrade documentation** + +Rename the document heading to `# Automated Dependency Upgrades`. Keep the Pi +section and add a `## Superpowers upgrades` section containing: + +````markdown +## Superpowers upgrades + +The `Superpowers dependency upgrade` workflow runs daily and opens a dedicated +review-gated pull request when `obra/superpowers` publishes a newer stable +GitHub Release. It does not combine Superpowers and Pi runtime changes. + +The pull request includes the upstream release body for every stable release +after the current pin through the target, ordered from oldest to newest. Missing +or empty release notes prevent pull-request creation. + +Run a non-mutating local validation with: + +```bash +node scripts/update-superpowers.mjs \ + --mode manual \ + --superpowers-version 6.0.3 \ + --validate-only \ + --skip-nix-hash \ + --summary-json .tmp/superpowers-summary.json +``` + +Omit `--superpowers-version` to validate discovery of the latest stable release. +Omit `--validate-only` and `--skip-nix-hash` only when intentionally preparing a +real Superpowers upgrade. +```` + +Document that both workflows use the same App secrets only after validation and +that neither auto-merges or publishes. + +- [ ] **Step 2: Run targeted tests** + +Run: + +```bash +node --test \ + scripts/superpowers-upgrade-lib.test.mjs \ + scripts/tracked-files.test.mjs \ + scripts/update-superpowers.test.mjs \ + scripts/superpowers-repository-contract.test.mjs \ + src/workflow/skill-pack.test.ts +``` + +Expected: all targeted tests PASS with zero failures. + +- [ ] **Step 3: Run the complete npm test suite** + +Run: + +```bash +npm test +``` + +Expected: all tests PASS with zero failures. + +- [ ] **Step 4: Run packed-artifact verification and lint** + +Run: + +```bash +node scripts/smoke-packed-artifact.mjs +npm run lint +``` + +Expected: packed artifact smoke test exits 0 and lint reports no errors. + +- [ ] **Step 5: Re-run workflow validation** + +Run: + +```bash +npx prettier --check .github/workflows/superpowers-upgrade.yml +nix run nixpkgs#actionlint -- .github/workflows/superpowers-upgrade.yml +``` + +Expected: both commands exit 0 with no workflow diagnostics. + +- [ ] **Step 6: Verify the updater is non-mutating in validation mode** + +Run: + +```bash +rm -rf .tmp/superpowers-final-validation +node scripts/update-superpowers.mjs \ + --mode scheduled \ + --validate-only \ + --skip-nix-hash \ + --summary-json .tmp/superpowers-final-validation/summary.json +git diff --exit-code +``` + +Expected: summary contains the current and target versions plus all in-range +release bodies, `changedFiles` is empty, and Git reports no tracked changes. + +- [ ] **Step 7: Verify npm/Nix dependency integration** + +Run: + +```bash +cp nix/package.nix .tmp/nix-package.nix.before-superpowers-check +scripts/update-npm-deps-hash.sh +cmp --silent .tmp/nix-package.nix.before-superpowers-check nix/package.nix +nix build .#patchmill --print-build-logs +``` + +Expected: the hash updater makes no change and the Nix package build exits 0. +The implementation PR intentionally leaves the current Superpowers dependency +pin unchanged; future automated upgrade PRs will commit the refreshed hash. + +- [ ] **Step 8: Review the final diff against the design** + +Run: + +```bash +git status --short +git diff --check +git diff --stat main...HEAD +git diff main...HEAD -- \ + .github/workflows/superpowers-upgrade.yml \ + scripts \ + src/workflow/skill-pack.test.ts \ + docs/pi-dependency-upgrades.md +``` + +Expected: only the planned workflow, scripts, tests, and documentation differ; +`package.json` remains pinned to `v6.0.3`; no whitespace errors are reported. + +- [ ] **Step 9: Commit Task 6** + +```bash +git add docs/pi-dependency-upgrades.md +git commit -m "docs(deps): document Superpowers upgrades" +``` + +- [ ] **Step 10: Request adversarial code review** + +Resolve and dispatch the canonical `reviewer` in fresh context with: + +- what was implemented; +- this plan and the approved design path; +- base SHA from `main` and current head SHA; +- confirmation that the implementation does not itself bump Superpowers; +- focus on release-note completeness, untrusted GitHub Markdown handling, + rollback, skill synchronization, credential timing, and workflow pathspecs. + +Apply only technically validated findings, then rerun every command from Steps +2-8 before branch completion. diff --git a/docs/specs/2026-08-03-superpowers-auto-upgrade-design.md b/docs/specs/2026-08-03-superpowers-auto-upgrade-design.md new file mode 100644 index 00000000..452aab95 --- /dev/null +++ b/docs/specs/2026-08-03-superpowers-auto-upgrade-design.md @@ -0,0 +1,305 @@ +# Superpowers Auto-Upgrade Design + +## Summary + +Patchmill will add a dedicated daily GitHub Actions workflow that discovers new +stable Superpowers releases, updates the pinned GitHub tarball dependency and +all related skill-pack metadata, validates the resulting package and Nix +integration, and opens a review-gated Superpowers-only pull request. + +The pull request will embed the upstream release notes for every intervening +release so reviewers can evaluate the complete change from the current pin to +the proposed target. + +## Goals + +- Upgrade `obra/superpowers` independently from Pi runtime dependencies. +- Open a separate pull request for each proposed Superpowers target. +- Keep every live Superpowers version reference synchronized. +- Regenerate Patchmill's checked-in project-local Superpowers skills and + metadata from the newly installed package. +- Include complete upstream release notes for every release after the current + pin through the target release. +- Fail before opening a pull request when dependency metadata, release notes, + required skill files, tests, packaging, or Nix validation are incomplete. +- Support scheduled discovery, explicit manual targets, and non-mutating + validation runs. + +## Non-goals + +- Generalizing the existing Pi dependency updater into a universal dependency + framework. +- Combining Pi and Superpowers changes in one pull request. +- Automatically merging or publishing dependency upgrades. +- Including draft or prerelease Superpowers versions. +- Automatically changing Patchmill's selected set of Superpowers skills when + upstream adds or removes skills. + +## Workflow Boundary + +Add `.github/workflows/superpowers-upgrade.yml`. The existing Pi dependency +workflow and updater remain unchanged. + +The dedicated workflow owns: + +- Superpowers release discovery; +- Superpowers dependency and skill-pack synchronization; +- release-note collection and rendering; +- Superpowers-specific pull-request identity; +- compatibility and packaging validation for the resulting change. + +The workflow runs daily on a schedule offset from the Pi dependency workflow. It +also supports `workflow_dispatch` inputs: + +- `superpowers-version`: an optional explicit stable version; +- `validate-only`: resolve and validate without changing files or opening a pull + request. + +Scheduled runs select the latest stable GitHub release. Manual runs use the +explicit version when supplied and otherwise resolve the latest stable release. + +## Components + +### Dedicated updater + +Add `scripts/update-superpowers.mjs` with a supporting testable library, such as +`scripts/superpowers-upgrade-lib.mjs`. + +The library owns pure or dependency-injected behavior: + +- parsing and formatting semantic versions and `v`-prefixed tags; +- parsing the current version from Patchmill's canonical GitHub tarball + dependency; +- constructing the canonical tarball URL; +- selecting releases in the upgrade range; +- validating release metadata and release-note bodies; +- validating package and lockfile consistency; +- rendering the Superpowers pull-request body. + +The CLI owns filesystem mutation, command execution, summary-file output, and +restoration after failures. + +### GitHub release source + +The updater queries GitHub Releases for `obra/superpowers` using the workflow's +standard GitHub token. Discovery excludes draft and prerelease entries. + +The current dependency remains in canonical form: + +```text +https://github.com/obra/superpowers/archive/refs/tags/vX.Y.Z.tar.gz +``` + +A malformed current dependency, malformed target tag, missing target release, or +target whose package version does not match its tag is a hard failure. + +### Pull-request creation + +The workflow mints the existing automation GitHub App token only after all +validation passes. It then creates or updates a branch named for the Superpowers +target, for example: + +```text +automation/superpowers-v6.2.0 +``` + +The pull-request title and commit subject use Superpowers-specific wording, for +example: + +```text +chore(deps): update Superpowers to v6.2.0 +``` + +The pull request remains review-gated and does not auto-merge or publish. + +## Upgrade Data Flow + +1. Check out the repository without persisting credentials. +2. Set up Node.js and install the current dependency graph with `npm ci`. +3. Install Nix with flakes enabled. +4. Read and validate the current Superpowers tarball pin from `package.json`. +5. Fetch the target stable GitHub release and all releases in the upgrade range. +6. Exit successfully without mutation when the target is not newer than the + current pin. +7. Update `package.json` to the target tarball URL. +8. Regenerate `package-lock.json` and `npm-shrinkwrap.json` using the + repository's existing lockfile-integrity safeguards. +9. Synchronize the target tag and tarball URL into `src/workflow/skill-pack.ts` + and `THIRD_PARTY_NOTICES.md`. +10. Reinstall the target dependency graph with `npm ci`. +11. Run `patchmill skills update` to copy configured upstream skills into + `.patchmill/skills` and regenerate `patchmill-skill-pack.json` with current + hashes and source metadata. +12. Refresh `nix/package.nix` with `scripts/update-npm-deps-hash.sh`. +13. Run all validation commands. +14. Render the pull-request body from the validated summary and collected + release metadata. +15. Mint the automation App token and create or update the dedicated pull + request. + +Local updater execution restores files it changed if mutation or validation +performed inside the updater fails. GitHub Actions failures leave no pull +request because token creation and pull-request creation occur last. + +## Synchronized Files + +A real Superpowers upgrade may change: + +- `package.json`; +- `package-lock.json`; +- `npm-shrinkwrap.json`; +- `nix/package.nix`; +- `src/workflow/skill-pack.ts`; +- `THIRD_PARTY_NOTICES.md`; +- `.patchmill/skills/patchmill-skill-pack.json`; +- configured Superpowers directories under `.patchmill/skills`. + +The updater's changed-file summary and `create-pull-request` pathspecs must +include additions, modifications, and removals under `.patchmill/skills` without +relying on Git being available to the updater. + +Historical references in changelogs, completed plans, and completed +specifications are not rewritten. + +## Skill-Pack Consistency + +`package.json` is the canonical installed dependency pin. Before completion, the +workflow verifies that: + +- `package.json` names the target canonical tarball URL; +- both lockfiles name the same root dependency URL; +- both lockfiles resolve `node_modules/superpowers` to the target package + version and target tarball; +- `PATCHMILL_RECOMMENDED_SKILL_PACK.source` names the same repository, tag, and + tarball URL; +- `THIRD_PARTY_NOTICES.md` links to the same upstream tag; +- checked-in project-local skill-pack metadata names the same source; +- every configured `source: "superpowers"` skill exists under + `node_modules/superpowers/skills//SKILL.md`; +- project-local managed files and their recorded hashes match the newly + installed source. + +Upstream additions do not automatically enter Patchmill's recommended pack. An +upstream removal or rename of a configured skill fails the upgrade for human +review. + +## Release Notes + +For a change from `v6.0.3` to `v6.2.0`, the pull request includes the release +bodies for `v6.1.0`, `v6.1.1`, and `v6.2.0` in ascending chronological/version +order. + +Each section contains: + +- the release tag; +- publication date; +- a link to the GitHub release; +- the upstream release body, preserving its Markdown. + +The current pinned release is excluded. Drafts, prereleases, releases at or +below the current pin, and releases above the target are excluded. + +Release discovery handles GitHub API pagination rather than assuming the +complete range fits on one response page. The workflow fails instead of opening +an incomplete pull request when: + +- an expected stable release in the version range cannot be fetched; +- a release has no non-whitespace release-note body; +- release tags are malformed or ambiguous; +- the explicit manual target does not identify a stable release. + +The pull-request body also lists changed files, validation commands, and the +current and target versions. + +## Error Handling + +The workflow fails before pull-request creation for: + +- GitHub API authentication, rate-limit, or pagination failures; +- malformed dependency specifications or release tags; +- missing or empty release notes; +- npm lockfile generation or integrity failures; +- inconsistent version references; +- missing configured upstream skill files; +- refusal by `patchmill skills update` to overwrite customized or unmanaged + project-local files; +- stale Nix npm dependency hashes; +- test, lint, packed-artifact, or Nix build failures. + +Error messages identify the affected release, package, path, expected value, and +actual value where applicable. Summary JSON is written on both success and +failure so workflow logs retain actionable context. + +## Validation + +A proposed upgrade runs: + +```bash +npm ci +node --test src/cli/commands/init/pi-dependency-contract.test.ts +npm test +node scripts/smoke-packed-artifact.mjs +npm run lint +scripts/update-npm-deps-hash.sh +nix build .#patchmill --print-build-logs +``` + +The existing Pi compatibility contract remains because Superpowers is consumed +through Patchmill's Pi-facing skill installation and execution paths. The full +npm suite covers skill installation, skill-pack updates, resolution, and +packaged behavior. + +After refreshing the Nix hash, the workflow runs the hash updater a second time +and fails if it changes `nix/package.nix`, proving the committed hash is +current. + +## Testing Strategy + +Automated tests cover reusable and regression-prone behavior: + +- current tarball version parsing; +- canonical target URL construction; +- GitHub release pagination and filtering; +- stable upgrade-range selection and ordering; +- scheduled, manual, validate-only, and no-update modes; +- malformed, missing, draft, and prerelease targets; +- missing release-note bodies; +- package and lockfile URL/version consistency; +- synchronized skill-pack source metadata; +- required upstream skill path checks; +- pull-request rendering with every intervening release body; +- failure summaries and restoration after partial mutation. + +Existing fixed-version assertions in `src/workflow/skill-pack.test.ts` become +consistency assertions against the canonical package pin, preventing future +upgrades from passing by changing an expectation alongside the implementation. + +No new test will assert static GitHub Actions YAML content. Workflow structure +is verified directly with the repository's available workflow/YAML tooling and a +manual `validate-only` dispatch or equivalent local updater invocation. + +## Documentation + +Update the dependency-upgrade documentation to describe: + +- the independent Pi and Superpowers workflows; +- scheduled and manual Superpowers runs; +- release-note range behavior; +- required GitHub App credentials; +- local validation commands; +- the review-gated, non-auto-merge policy. + +## Acceptance Criteria + +- A daily scheduled run detects a newer stable Superpowers release independently + of Pi versions. +- A Superpowers-only branch and pull request are created after successful + validation. +- The pull request embeds non-empty release notes for every stable release after + the current pin through the target. +- All package, lockfile, Nix, notice, skill-pack source, project-local metadata, + and skill-file references agree on the target release. +- Missing notes, missing configured skills, inconsistent metadata, or failed + validation prevent pull-request creation. +- No-update and validate-only runs create no branch or pull request. +- The Pi dependency workflow remains behaviorally unchanged. diff --git a/scripts/superpowers-repository-contract.test.mjs b/scripts/superpowers-repository-contract.test.mjs new file mode 100644 index 00000000..85d185c0 --- /dev/null +++ b/scripts/superpowers-repository-contract.test.mjs @@ -0,0 +1,97 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { access, readFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { + assertLockfilesMatchSuperpowersTarget, + getCurrentSuperpowersVersion, + tagForVersion, + tarballUrlForVersion, +} from "./superpowers-upgrade-lib.mjs"; + +const rootDir = dirname(dirname(fileURLToPath(import.meta.url))); +const readJson = async (path) => JSON.parse(await readFile(path, "utf8")); + +test("repository Superpowers references and managed skills agree with the canonical pin", async (t) => { + const metadataPath = join( + rootDir, + ".patchmill/skills/patchmill-skill-pack.json", + ); + try { + await access(metadataPath); + } catch (error) { + if (error.code === "ENOENT") { + t.skip( + "project-local managed skills are not included in packaged source", + ); + return; + } + throw error; + } + const [packageJson, packageLock, shrinkwrap, notices, metadata] = + await Promise.all( + [ + "package.json", + "package-lock.json", + "npm-shrinkwrap.json", + "THIRD_PARTY_NOTICES.md", + ".patchmill/skills/patchmill-skill-pack.json", + ].map((path) => + path.endsWith(".json") + ? readJson(join(rootDir, path)) + : readFile(join(rootDir, path), "utf8"), + ), + ); + const version = getCurrentSuperpowersVersion(packageJson); + const tag = tagForVersion(version); + const tarballUrl = tarballUrlForVersion(version); + assertLockfilesMatchSuperpowersTarget({ + packageJson, + packageLock, + shrinkwrap, + targetVersion: version, + }); + const { PATCHMILL_RECOMMENDED_SKILL_PACK } = await import( + pathToFileURL(join(rootDir, "src/workflow/skill-pack.ts")).href + ); + assert.deepEqual(PATCHMILL_RECOMMENDED_SKILL_PACK.source, { + type: "github-release", + repository: "obra/superpowers", + tag, + tarballUrl, + }); + assert.deepEqual( + metadata.pack.source, + PATCHMILL_RECOMMENDED_SKILL_PACK.source, + ); + const noticeUrl = `https://github.com/obra/superpowers/tree/${tag}/skills`; + assert.equal( + ( + notices.match( + new RegExp(noticeUrl.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"), "gu"), + ) ?? [] + ).length, + 1, + ); + const require = createRequire(import.meta.url); + const superpowersPackage = require("superpowers/package.json"); + const superpowersRoot = dirname(require.resolve("superpowers/package.json")); + assert.equal(superpowersPackage.version, version); + for (const skill of PATCHMILL_RECOMMENDED_SKILL_PACK.skills.filter( + ({ source }) => source === "superpowers", + )) + await t.test(`installed upstream skill exists: ${skill.name}`, () => + access(join(superpowersRoot, "skills", skill.name, "SKILL.md")), + ); + for (const file of metadata.files) { + const content = await readFile(join(rootDir, file.path)); + assert.equal( + createHash("sha256").update(content).digest("hex"), + file.sha256, + `managed file hash: ${file.path}`, + ); + } +}); diff --git a/scripts/superpowers-upgrade-lib.mjs b/scripts/superpowers-upgrade-lib.mjs new file mode 100644 index 00000000..2893cead --- /dev/null +++ b/scripts/superpowers-upgrade-lib.mjs @@ -0,0 +1,327 @@ +export const SUPERPOWERS_PACKAGE = "superpowers"; +export const SUPERPOWERS_REPOSITORY = "obra/superpowers"; + +const stableVersionPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/u; +const canonicalSpecPattern = + /^https:\/\/github\.com\/obra\/superpowers\/archive\/refs\/tags\/v((?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*))\.tar\.gz$/u; +const githubApi = "https://api.github.com"; + +export function normalizeVersion(value, label = "Superpowers version") { + if (typeof value !== "string") { + throw new Error(`${label} must be a stable X.Y.Z version; found ${value}`); + } + const normalized = value.startsWith("v") ? value.slice(1) : value; + if (!stableVersionPattern.test(normalized)) { + throw new Error(`${label} must be a stable X.Y.Z version; found ${value}`); + } + return normalized; +} + +export function tagForVersion(version) { + return `v${normalizeVersion(version)}`; +} + +export function tarballUrlForVersion(version) { + return `https://github.com/obra/superpowers/archive/refs/tags/${tagForVersion(version)}.tar.gz`; +} + +export function compareVersions(a, b) { + const left = normalizeVersion(a).split(".").map(Number); + const right = normalizeVersion(b).split(".").map(Number); + for (let index = 0; index < left.length; index += 1) { + if (left[index] !== right[index]) return left[index] - right[index]; + } + return 0; +} + +export function getCurrentSuperpowersVersion(packageJson) { + const spec = packageJson.dependencies?.[SUPERPOWERS_PACKAGE]; + const match = canonicalSpecPattern.exec(spec ?? ""); + if (!match) { + throw new Error( + `${SUPERPOWERS_PACKAGE} must use the canonical GitHub tag tarball; found ${spec ?? "missing"}`, + ); + } + return normalizeVersion(match[1]); +} + +export function normalizeGitHubRelease(release) { + if (release?.draft || release?.prerelease) return undefined; + const version = normalizeVersion(release?.tag_name, "GitHub release tag"); + const body = release?.body ?? ""; + for (const [key, value] of Object.entries({ + name: release?.name ?? release?.tag_name, + htmlUrl: release?.html_url, + publishedAt: release?.published_at, + body, + })) { + if (typeof value !== "string") { + throw new Error( + `GitHub release ${tagForVersion(version)} has invalid ${key}`, + ); + } + } + return { + tag: tagForVersion(version), + version, + name: release.name ?? release.tag_name, + htmlUrl: release.html_url, + publishedAt: release.published_at, + body, + }; +} + +function githubHeaders(token) { + return { + accept: "application/vnd.github+json", + "x-github-api-version": "2022-11-28", + ...(token ? { authorization: `Bearer ${token}` } : {}), + }; +} + +function nextLink(link) { + if (!link) return undefined; + return link + .split(",") + .map((entry) => entry.trim()) + .find((entry) => /;\s*rel="next"$/u.test(entry)) + ?.match(/^<([^>]+)>/u)?.[1]; +} + +async function githubJson(fetchImpl, url, token, repository) { + let response; + try { + response = await fetchImpl(url, { headers: githubHeaders(token) }); + } catch (error) { + throw new Error( + `GitHub request failed for ${repository} ${url}: ${error.message}`, + ); + } + if (!response.ok) { + throw new Error( + `GitHub request failed for ${repository} ${url}: ${response.status} ${response.statusText}`, + ); + } + return { body: await response.json(), response }; +} + +export async function fetchStableReleases({ + fetchImpl = fetch, + token, + repository = SUPERPOWERS_REPOSITORY, +}) { + let url = `${githubApi}/repos/${repository}/releases?per_page=100&page=1`; + const releases = []; + while (url) { + const { body, response } = await githubJson( + fetchImpl, + url, + token, + repository, + ); + if (!Array.isArray(body)) + throw new Error( + `GitHub releases response for ${repository} is not an array`, + ); + for (const release of body) { + const normalized = normalizeGitHubRelease(release); + if (normalized) releases.push(normalized); + } + url = nextLink(response.headers.get("link")); + } + const byVersion = new Map(); + for (const release of releases) { + if (byVersion.has(release.version)) + throw new Error(`Ambiguous stable release ${release.tag}`); + byVersion.set(release.version, release); + } + return [...byVersion.values()].sort((a, b) => + compareVersions(a.version, b.version), + ); +} + +export async function fetchReleasePackageVersion({ + fetchImpl = fetch, + token, + repository = SUPERPOWERS_REPOSITORY, + tag, +}) { + const normalizedTag = tagForVersion(tag); + const url = `${githubApi}/repos/${repository}/contents/package.json?ref=${encodeURIComponent(normalizedTag)}`; + const { body } = await githubJson(fetchImpl, url, token, repository); + if (body?.encoding !== "base64" || typeof body.content !== "string") { + throw new Error( + `GitHub package.json response for ${repository}@${normalizedTag} is not base64 content`, + ); + } + let packageJson; + try { + packageJson = JSON.parse( + Buffer.from(body.content.replace(/\s/g, ""), "base64").toString("utf8"), + ); + } catch (error) { + throw new Error( + `GitHub package.json for ${repository}@${normalizedTag} is invalid: ${error.message}`, + ); + } + return normalizeVersion( + packageJson.version, + `package version for ${normalizedTag}`, + ); +} + +export function resolveSuperpowersUpgrade({ + currentVersion, + requestedVersion, + releases, +}) { + const current = normalizeVersion( + currentVersion, + "Current Superpowers version", + ); + const normalized = releases + .map((release) => { + if (!release) throw new Error("GitHub release is missing"); + return release.version ? release : normalizeGitHubRelease(release); + }) + .sort((a, b) => compareVersions(a.version, b.version)); + const target = normalizeVersion( + requestedVersion ?? normalized.at(-1)?.version, + "Target Superpowers version", + ); + if (compareVersions(target, current) < 0) { + throw new Error( + `Target Superpowers version ${target} is older than current version ${current}`, + ); + } + const targetRelease = normalized.find( + (release) => release.version === target, + ); + if (!targetRelease) + throw new Error( + `Requested stable release ${tagForVersion(target)} was not found`, + ); + if (compareVersions(target, current) === 0) { + return { + noUpdate: true, + currentVersion: current, + targetVersion: target, + targetTag: targetRelease.tag, + releases: [], + }; + } + const selected = normalized.filter( + (release) => + compareVersions(release.version, current) > 0 && + compareVersions(release.version, target) <= 0, + ); + for (const release of selected) { + if (!release.body.trim()) + throw new Error(`${release.tag} has no release-note body`); + } + return { + noUpdate: false, + currentVersion: current, + targetVersion: target, + targetTag: targetRelease.tag, + releases: selected, + }; +} + +function assertEqual(label, actual, expected) { + if (actual !== expected) + throw new Error( + `${label}: expected ${expected}; found ${actual ?? "missing"}`, + ); +} + +export function assertLockfilesMatchSuperpowersTarget({ + packageJson, + packageLock, + shrinkwrap, + targetVersion, +}) { + const expectedSpec = tarballUrlForVersion(targetVersion); + assertEqual( + "package.json dependencies.superpowers", + packageJson.dependencies?.[SUPERPOWERS_PACKAGE], + expectedSpec, + ); + for (const [label, lockfile] of [ + ["package-lock.json", packageLock], + ["npm-shrinkwrap.json", shrinkwrap], + ]) { + const rootSpec = + lockfile.packages?.[""]?.dependencies?.[SUPERPOWERS_PACKAGE]; + const installed = + lockfile.packages?.[`node_modules/${SUPERPOWERS_PACKAGE}`]; + assertEqual(`${label} root dependency`, rootSpec, expectedSpec); + assertEqual( + `${label} installed version`, + installed?.version, + normalizeVersion(targetVersion), + ); + assertEqual( + `${label} installed resolved`, + installed?.resolved, + expectedSpec, + ); + if (typeof installed?.integrity !== "string" || !installed.integrity) { + throw new Error( + `${label} installed integrity: expected a non-empty value; found ${installed?.integrity ?? "missing"}`, + ); + } + } +} + +function markdownCodeFence(body) { + const longestBacktickRun = Math.max( + 0, + ...(body.match(/`+/gu) ?? []).map((run) => run.length), + ); + return "`".repeat(Math.max(3, longestBacktickRun + 1)); +} + +function markdownCodeSpan(value) { + const fence = markdownCodeFence(value); + return `${fence}${value}${fence}`; +} + +export function renderSuperpowersPullRequestBody(summary) { + const lines = [ + "## Superpowers dependency upgrade", + "", + `Updates Superpowers from \`${summary.currentVersion}\` to \`${summary.targetVersion}\`.`, + "This pull request is review-gated and does not auto-merge or publish.", + "", + "## Changed files", + ...summary.changedFiles.map((path) => `- ${markdownCodeSpan(path)}`), + "", + "## Validation", + ...summary.validationCommands.map((command) => `- \`${command}\``), + "", + "## Upstream release notes", + ]; + for (const release of summary.releases) { + if (!release.body?.trim()) + throw new Error(`${release.tag} has no release-note body`); + lines.push( + "", + `### ${release.tag}`, + "", + `Published: ${release.publishedAt}`, + "", + `Release: ${release.htmlUrl}`, + "", + markdownCodeFence(release.body), + release.body, + markdownCodeFence(release.body), + ); + } + const body = `${lines.join("\n")}\n`; + if (body.length > 65_536) + throw new Error( + "Superpowers pull-request body exceeds GitHub's 65,536-character limit", + ); + return body; +} diff --git a/scripts/superpowers-upgrade-lib.test.mjs b/scripts/superpowers-upgrade-lib.test.mjs new file mode 100644 index 00000000..ac404e1b --- /dev/null +++ b/scripts/superpowers-upgrade-lib.test.mjs @@ -0,0 +1,244 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + assertLockfilesMatchSuperpowersTarget, + compareVersions, + fetchReleasePackageVersion, + fetchStableReleases, + getCurrentSuperpowersVersion, + normalizeGitHubRelease, + normalizeVersion, + renderSuperpowersPullRequestBody, + resolveSuperpowersUpgrade, + tagForVersion, + tarballUrlForVersion, +} from "./superpowers-upgrade-lib.mjs"; + +const spec = tarballUrlForVersion("6.0.3"); +const release = (tag, overrides = {}) => ({ + tag_name: tag, + name: tag, + html_url: `https://github.com/obra/superpowers/releases/tag/${tag}`, + published_at: "2026-07-24T00:00:00Z", + body: `Notes for ${tag}`, + draft: false, + prerelease: false, + ...overrides, +}); +const response = (body, options = {}) => + new Response(JSON.stringify(body), { + status: options.status ?? 200, + headers: options.link ? { link: options.link } : {}, + }); + +test("normalizes strict stable versions and canonical dependency specs", () => { + assert.equal(normalizeVersion("v6.2.0"), "6.2.0"); + assert.equal(tagForVersion("6.2.0"), "v6.2.0"); + assert.equal( + tarballUrlForVersion("6.2.0"), + "https://github.com/obra/superpowers/archive/refs/tags/v6.2.0.tar.gz", + ); + assert.equal(compareVersions("6.1.1", "6.2.0"), -1); + assert.equal( + getCurrentSuperpowersVersion({ dependencies: { superpowers: spec } }), + "6.0.3", + ); + for (const invalid of ["6.2", "6.2.0-beta", "06.2.0"]) + assert.throws(() => normalizeVersion(invalid), /stable X\.Y\.Z/); +}); + +test("paginates stable releases and selects complete ordered range", async () => { + const requests = []; + const releases = await fetchStableReleases({ + token: "token", + fetchImpl: async (url, options) => { + requests.push({ url: String(url), options }); + return String(url).endsWith("page=1") + ? response( + [release("v6.2.0"), release("v6.2.0-beta", { prerelease: true })], + { + link: '; rel="next"', + }, + ) + : response([ + release("v6.1.1"), + release("v6.1.0"), + release("v6.0.3"), + release("v6.0.2", { draft: true }), + ]); + }, + }); + assert.equal(requests.length, 2); + assert.equal(requests[0].options.headers.authorization, "Bearer token"); + assert.deepEqual( + resolveSuperpowersUpgrade({ + currentVersion: "6.0.3", + releases, + }).releases.map(({ tag }) => tag), + ["v6.1.0", "v6.1.1", "v6.2.0"], + ); +}); + +test("requires selected releases to have notes and validates no-update targets", () => { + assert.throws( + () => + resolveSuperpowersUpgrade({ + currentVersion: "6.0.3", + requestedVersion: "6.2.0", + releases: [ + normalizeGitHubRelease(release("v6.1.0", { body: " " })), + normalizeGitHubRelease(release("v6.2.0")), + ], + }), + /v6\.1\.0 has no release-note body/, + ); + assert.throws( + () => + resolveSuperpowersUpgrade({ + currentVersion: "6.0.3", + requestedVersion: "6.2.0", + releases: [ + normalizeGitHubRelease(release("v6.1.0", { body: null })), + normalizeGitHubRelease(release("v6.2.0")), + ], + }), + /v6\.1\.0 has no release-note body/, + ); + assert.doesNotThrow(() => + resolveSuperpowersUpgrade({ + currentVersion: "6.0.3", + requestedVersion: "6.2.0", + releases: [ + normalizeGitHubRelease(release("v6.0.2", { body: null })), + normalizeGitHubRelease(release("v6.2.0")), + normalizeGitHubRelease(release("v6.3.0", { body: null })), + ], + }), + ); + assert.equal( + resolveSuperpowersUpgrade({ + currentVersion: "6.0.3", + requestedVersion: "6.0.3", + releases: [normalizeGitHubRelease(release("v6.0.3"))], + }).noUpdate, + true, + ); + assert.throws( + () => + resolveSuperpowersUpgrade({ + currentVersion: "6.0.3", + requestedVersion: "6.0.3", + releases: [], + }), + /Requested stable release v6\.0\.3 was not found/, + ); +}); + +test("reads release package versions and renders ordered release notes", async () => { + assert.equal( + await fetchReleasePackageVersion({ + tag: "v6.2.0", + fetchImpl: async () => + response({ + encoding: "base64", + content: Buffer.from(JSON.stringify({ version: "6.2.0" })).toString( + "base64", + ), + }), + }), + "6.2.0", + ); + const body = renderSuperpowersPullRequestBody({ + currentVersion: "6.0.3", + targetVersion: "6.2.0", + changedFiles: ["package.json"], + validationCommands: ["npm test"], + releases: ["v6.1.0", "v6.1.1", "v6.2.0"].map((tag) => + normalizeGitHubRelease(release(tag)), + ), + }); + assert.ok(body.indexOf("### v6.1.0") < body.indexOf("### v6.1.1")); + assert.match(body, /does not auto-merge or publish/); + const contained = renderSuperpowersPullRequestBody({ + currentVersion: "6.0.3", + targetVersion: "6.2.0", + changedFiles: [], + validationCommands: [], + releases: [ + normalizeGitHubRelease( + release("v6.2.0", { + body: "Fixes #123\n