From 98645096322ba0d5488d40c3e2abafb7493df01a Mon Sep 17 00:00:00 2001 From: Bradley Taylor Date: Tue, 26 May 2026 19:44:32 -0700 Subject: [PATCH 1/3] fix(config): default project to 0 so removing the YAML key disables the board MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous default of 2 meant that omitting the project: line in .alpha-loop.yaml silently fell back to project number 2 (this repo's own board), instead of disabling project board transitions. With project: 0 every project-related call site short-circuits cleanly: - pollIssues falls back to label-based polling - updateProjectStatus returns early - ensureProjectStatuses logs "No project board configured — skipping" - addIssueToProject is guarded at every call site Also comments out project: 2 in this repo's own .alpha-loop.yaml since the board isn't actively used. Co-Authored-By: Claude Opus 4.7 (1M context) --- .alpha-loop.yaml | 2 +- src/lib/config.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.alpha-loop.yaml b/.alpha-loop.yaml index 26e1394..381c6d7 100644 --- a/.alpha-loop.yaml +++ b/.alpha-loop.yaml @@ -11,7 +11,7 @@ # === Project ================================================================ # Identifies the GitHub repo + project board the loop reads from. repo: bradtaylorsf/alpha-loop -project: 2 # GitHub Project number (from project URL: /projects/N) +# project: 0 # GitHub Project number (from project URL: /projects/N). 0 or omitted = disabled. # milestone: "" # Only process issues in this milestone (empty = all) # === Agent ================================================================== diff --git a/src/lib/config.ts b/src/lib/config.ts index ceacc94..3ff9195 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -181,7 +181,7 @@ export type Config = { const DEFAULTS: Config = { repo: '', repoOwner: '', - project: 2, + project: 0, agent: 'claude', model: '', reviewModel: '', From c8698839cf21e1c7b775223f10b50eeda2c45ceb Mon Sep 17 00:00:00 2001 From: Bradley Taylor Date: Tue, 26 May 2026 19:44:43 -0700 Subject: [PATCH 2/3] ci(release): switch to bump-in-PR deploy flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the "CI stamps version + opens bump-back PR" model with a local-first flow that eliminates the two-PR cascade and the infinite-loop risk we hit on the v2.0.0 → v2.0.4 chain. New flow: - pnpm deploy (local): computes next semver from conventional commits, stamps package.json, opens a release PR with the bump already in it. - CI: just publishes whatever version is in package.json after merge. Gates on "package.json version != latest tag" — if they match, skip. - pnpm release:watch: tails the workflow run, verifies local == npm == tag after publish, pulls master so local stays in sync. Removed from the workflow: - "Stamp version" step (no longer rewriting package.json in CI) - "Sync version bump back to master via PR" step (no follow-up PR needed) - Loop guard regex (no possible cascade — version bump and feature are in the same merge commit) Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/release.yml | 152 ++++++---------------------------- package.json | 4 +- scripts/deploy.mjs | 145 ++++++++++++++++++++++++++++++++ scripts/release-watch.mjs | 111 +++++++++++++++++++++++++ 4 files changed, 284 insertions(+), 128 deletions(-) create mode 100644 scripts/deploy.mjs create mode 100644 scripts/release-watch.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 424b410..33ce147 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,14 +10,13 @@ on: workflow_dispatch: inputs: reason: - description: 'Why are you manually triggering a release? (e.g. "validate post-#271 commit-back logic")' + description: 'Why are you manually triggering a release? (e.g. "republish v2.0.5")' required: false default: 'manual release' permissions: contents: write id-token: write - pull-requests: write jobs: test: @@ -53,160 +52,59 @@ jobs: - run: pnpm install --frozen-lockfile - # Determine version from git tags (source of truth) + commit messages. - # The stamped package.json is committed back to master after publish so - # `--version` on a fresh source clone matches what's published on npm. - # - # Loop guard: if the latest commit on master IS the auto-generated - # release-bump-back commit, skip this run entirely. Otherwise the - # bump-PR's own merge would trigger another release, which would - # generate another bump-PR, in a perpetual chain. - - name: Determine version - id: version + # Bump-in-PR model: the release PR ALREADY contains the version bump + # (created by `pnpm release` locally). CI just publishes whatever version + # is in package.json. If it matches the latest tag, this commit isn't a + # release — skip. No rewrites, no follow-up PR, no infinite-loop risk. + - name: Check for release + id: gate run: | - # Loop guard — skip when the latest commit is the bot's - # auto-bump merge. Match the squash-merged form too: - # "chore(release): bump version to v2.0.3 (#274)". - LATEST_MSG=$(git log -1 --format="%s") - if echo "$LATEST_MSG" | grep -qE '^chore\(release\): bump version to v[0-9]+\.[0-9]+\.[0-9]+( \(#[0-9]+\))?$'; then - echo "Latest commit is the bot's auto-bump — skipping this release to avoid an infinite loop." - echo "Subject: $LATEST_MSG" - echo "skip=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - - # Get latest tag — this is the source of truth, NOT package.json + PKG_VERSION=$(node scripts/read-package-version.mjs) LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") - if [ -z "$LAST_TAG" ]; then - # First release — use package.json version - NEW_VERSION=$(node scripts/read-package-version.mjs) - echo "new_version=$NEW_VERSION" >> "$GITHUB_OUTPUT" - echo "skip=false" >> "$GITHUB_OUTPUT" - exit 0 - fi + LAST_VERSION=${LAST_TAG#v} - CURRENT=${LAST_TAG#v} - echo "current=$CURRENT" >> "$GITHUB_OUTPUT" + echo "package.json: v$PKG_VERSION" + echo "latest tag: ${LAST_TAG:-(none)}" - COMMITS=$(git log "$LAST_TAG"..HEAD --oneline --format="%s") - - # Skip if no new commits since last tag - if [ -z "$COMMITS" ]; then + if [ "$PKG_VERSION" = "$LAST_VERSION" ]; then echo "skip=true" >> "$GITHUB_OUTPUT" + echo "→ Version unchanged from latest tag. Skipping release." exit 0 fi - # Determine bump type from conventional commits - if echo "$COMMITS" | grep -qiE "BREAKING CHANGE|^.*!:"; then - BUMP="major" - elif echo "$COMMITS" | grep -qE "^feat"; then - BUMP="minor" - else - BUMP="patch" - fi - - # Compute new version - NEW_VERSION=$(node -e " - const [major, minor, patch] = '$CURRENT'.split('.').map(Number); - const bump = '$BUMP'; - if (bump === 'major') console.log((major+1)+'.0.0'); - else if (bump === 'minor') console.log(major+'.'+(minor+1)+'.0'); - else console.log(major+'.'+minor+'.'+(patch+1)); - ") - - echo "new_version=$NEW_VERSION" >> "$GITHUB_OUTPUT" - echo "bump=$BUMP" >> "$GITHUB_OUTPUT" echo "skip=false" >> "$GITHUB_OUTPUT" - echo "Computed: $CURRENT + $BUMP = $NEW_VERSION" + echo "version=$PKG_VERSION" >> "$GITHUB_OUTPUT" + echo "→ Will publish v$PKG_VERSION" - # Stamp package metadata before building and publishing - - name: Stamp version - if: steps.version.outputs.skip != 'true' - run: | - NEW_VERSION=${{ steps.version.outputs.new_version }} - - node -e " - const pkg = require('./package.json'); - pkg.version = '$NEW_VERSION'; - require('fs').writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); - " - - # Rebuild with correct version - pnpm build + - name: Build for publish + if: steps.gate.outputs.skip != 'true' + run: pnpm build - name: Publish to npm - if: steps.version.outputs.skip != 'true' + if: steps.gate.outputs.skip != 'true' run: npm publish --provenance --access public env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Create tag and release - if: steps.version.outputs.skip != 'true' + if: steps.gate.outputs.skip != 'true' env: GH_TOKEN: ${{ github.token }} - NEW_VERSION: ${{ steps.version.outputs.new_version }} + NEW_VERSION: ${{ steps.gate.outputs.version }} run: | - # Create and push tag (no commit to master needed) git tag "v$NEW_VERSION" git push origin "v$NEW_VERSION" - # Generate release notes from commits since last tag - LAST_TAG=$(git describe --tags --abbrev=0 HEAD 2>/dev/null || echo "") - PREV_TAG=$(git describe --tags --abbrev=0 "$LAST_TAG^" 2>/dev/null || echo "") + # Release notes: commits since the previous tag (exclusive of the + # new tag itself, which doesn't exist as a ref point yet). + PREV_TAG=$(git describe --tags --abbrev=0 "v$NEW_VERSION^" 2>/dev/null || echo "") if [ -z "$PREV_TAG" ]; then NOTES=$(git log --oneline --format="- %s" | head -20) else - NOTES=$(git log "$PREV_TAG".."$LAST_TAG^" --oneline --format="- %s") + NOTES=$(git log "$PREV_TAG"..HEAD --oneline --format="- %s") fi gh release create "v$NEW_VERSION" \ --title "v$NEW_VERSION" \ --notes "$NOTES" \ --latest - - # Sync the stamped package.json back to master via PR. Master is - # protected by a repo rule that requires changes go through PRs, - # so a direct push from CI is rejected. Without this step, a - # fresh `git clone` + `node dist/cli.js --version` would report - # the stale value left over from before the last release. - # - # Flow: push a chore/release-bump-vX.Y.Z branch, open a PR, - # merge it. GitHub's recursive-trigger prevention means the - # GITHUB_TOKEN-merged push to master does NOT fire a new - # workflow run, so there is no infinite loop. continue-on-error - # keeps a hiccup here from failing the whole release once the - # npm publish + git tag have already succeeded. - - name: Sync version bump back to master via PR - if: steps.version.outputs.skip != 'true' - continue-on-error: true - env: - GH_TOKEN: ${{ github.token }} - NEW_VERSION: ${{ steps.version.outputs.new_version }} - run: | - set -e - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - - git add package.json - if git diff --cached --quiet; then - echo "package.json already at v$NEW_VERSION on master — nothing to sync." - exit 0 - fi - - BUMP_BRANCH="chore/release-bump-v$NEW_VERSION" - git checkout -b "$BUMP_BRANCH" - git commit -m "chore(release): bump version to v$NEW_VERSION" - git push -u origin "$BUMP_BRANCH" - - gh pr create \ - --base master \ - --head "$BUMP_BRANCH" \ - --title "chore(release): bump version to v$NEW_VERSION" \ - --body "Auto-generated after publishing v$NEW_VERSION to npm. Syncs source-of-truth package.json with the released version so the CLI reports the correct version on fresh source clones." - - # No --admin: master's ruleset only requires changes go through a - # PR; it does not require approvals or status checks, so the bot - # can merge directly. If the user later adds those requirements, - # the merge fails loudly and continue-on-error keeps the release - # green so a human can take over the sync manually. - gh pr merge "$BUMP_BRANCH" --squash --delete-branch diff --git a/package.json b/package.json index a21e153..bd64292 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,9 @@ "test": "jest --runInBand", "test:unit": "jest --runInBand", "test:watch": "jest --watch", - "prepublishOnly": "pnpm build" + "prepublishOnly": "pnpm build", + "release": "node scripts/deploy.mjs", + "release:watch": "node scripts/release-watch.mjs" }, "keywords": [ "ai", diff --git a/scripts/deploy.mjs b/scripts/deploy.mjs new file mode 100644 index 0000000..e612c2c --- /dev/null +++ b/scripts/deploy.mjs @@ -0,0 +1,145 @@ +#!/usr/bin/env node +// Local deploy helper. Computes the next semver from conventional commits +// since the last git tag, stamps package.json, commits + pushes a release +// branch, and opens a PR. CI then publishes the exact version in package.json +// after the PR is merged — no bump-back PR, no rewrites, no loop guard. +// +// Usage: +// pnpm release # auto-detect bump from commits +// pnpm release --patch # force patch +// pnpm release --minor # force minor +// pnpm release --major # force major +// pnpm release --dry-run # print plan without touching anything + +import { execSync, spawnSync } from 'node:child_process'; +import { readFileSync, writeFileSync } from 'node:fs'; + +const args = new Set(process.argv.slice(2)); +const DRY = args.has('--dry-run'); +const FORCE_BUMP = ['--major', '--minor', '--patch'].find((f) => args.has(f))?.slice(2); + +function sh(cmd, opts = {}) { + return execSync(cmd, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], ...opts }).trim(); +} + +function shOk(cmd) { + const r = spawnSync('sh', ['-c', cmd], { stdio: 'inherit' }); + if (r.status !== 0) { + console.error(`\n✗ command failed: ${cmd}`); + process.exit(r.status ?? 1); + } +} + +function check(label, fn) { + process.stdout.write(` • ${label}... `); + try { + fn(); + process.stdout.write('ok\n'); + } catch (err) { + process.stdout.write('FAIL\n'); + console.error(`\n${err.message}`); + process.exit(1); + } +} + +function computeBump(commits) { + if (FORCE_BUMP) return FORCE_BUMP; + if (/BREAKING CHANGE|^[a-z]+(\([^)]+\))?!:/m.test(commits)) return 'major'; + if (/^feat(\([^)]+\))?:/m.test(commits)) return 'minor'; + return 'patch'; +} + +function bumpVersion(current, kind) { + const [maj, min, pat] = current.split('.').map(Number); + if (kind === 'major') return `${maj + 1}.0.0`; + if (kind === 'minor') return `${maj}.${min + 1}.0`; + return `${maj}.${min}.${pat + 1}`; +} + +console.log('\n=== alpha-loop deploy ===\n'); + +// Preflight +console.log('Preflight:'); +check('git repo', () => sh('git rev-parse --is-inside-work-tree')); +check('gh authenticated', () => sh('gh auth status', { stdio: ['pipe', 'pipe', 'pipe'] })); +check('no uncommitted tracked changes', () => { + // Only block on modified/staged tracked files. Untracked files are fine — + // they won't end up in the release commit unless explicitly added. + const dirty = sh('git status --porcelain --untracked-files=no'); + if (dirty) throw new Error(`Uncommitted changes to tracked files:\n${dirty}\n\nCommit your work first, then run pnpm release.`); +}); + +const branch = sh('git rev-parse --abbrev-ref HEAD'); +check(`branch (${branch})`, () => { + if (branch === 'HEAD') throw new Error('Detached HEAD. Check out a branch first.'); +}); + +// Version computation +const pkgPath = new URL('../package.json', import.meta.url); +const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')); +const currentLocal = pkg.version; + +let lastTag = ''; +try { lastTag = sh('git describe --tags --abbrev=0'); } catch { /* first release */ } +const currentTagged = lastTag ? lastTag.replace(/^v/, '') : '0.0.0'; + +const range = lastTag ? `${lastTag}..HEAD` : 'HEAD'; +const commits = sh(`git log ${range} --format='%s%n%b'`); +if (!commits) { + console.log('\nNothing to release — no commits since ' + (lastTag || 'repo init') + '.'); + process.exit(0); +} + +const bump = computeBump(commits); +const nextVersion = bumpVersion(currentTagged, bump); + +console.log(`\nVersion plan:`); +console.log(` current tag: ${lastTag || '(none)'}`); +console.log(` current local: v${currentLocal}`); +console.log(` bump type: ${bump}${FORCE_BUMP ? ' (forced)' : ' (from commits)'}`); +console.log(` next version: v${nextVersion}`); + +const releaseBranch = /^chore\/release-v\d+\.\d+\.\d+$/.test(branch) + ? branch + : `chore/release-v${nextVersion}`; +console.log(` release branch: ${releaseBranch}${releaseBranch === branch ? ' (current)' : ' (will create)'}`); + +const oneline = sh(`git log ${range} --format='%h %s'`); +const notes = oneline.split('\n').map((l) => `- ${l}`).join('\n'); +console.log(`\nCommits since ${lastTag || 'repo init'}:`); +console.log(oneline.split('\n').map((l) => ` ${l}`).join('\n')); + +if (DRY) { + console.log('\n[dry-run] No changes made.'); + process.exit(0); +} + +// Execute +console.log('\nExecuting:'); +if (branch !== releaseBranch) { + shOk(`git checkout -b "${releaseBranch}"`); +} + +// Stamp package.json +pkg.version = nextVersion; +writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n'); +shOk(`git add package.json`); + +const commitMsg = `chore(release): release v${nextVersion}\n\n${notes}`; +const escapedMsg = commitMsg.replace(/'/g, `'\\''`); +shOk(`git commit -m '${escapedMsg}'`); +shOk(`git push -u origin "${releaseBranch}"`); + +// Open PR +const prBody = `Releases v${nextVersion} (${bump} bump).\n\n## Changes\n\n${notes}\n\n---\nThis PR was created by \`pnpm release\`. After merge, run \`pnpm release:watch\` to follow the publish.`; +const prBodyEsc = prBody.replace(/'/g, `'\\''`); +const prResult = sh( + `gh pr create --base master --head "${releaseBranch}" --title "chore(release): release v${nextVersion}" --body '${prBodyEsc}'` +); +const prUrl = prResult.trim().split('\n').pop(); + +console.log(`\n✓ PR opened: ${prUrl}`); +console.log(`\nNext steps:`); +console.log(` 1. Review and merge the PR`); +console.log(` 2. Run: pnpm release:watch`); +console.log(` (tails CI, verifies local==npm==tag once published)\n`); diff --git a/scripts/release-watch.mjs b/scripts/release-watch.mjs new file mode 100644 index 0000000..da02044 --- /dev/null +++ b/scripts/release-watch.mjs @@ -0,0 +1,111 @@ +#!/usr/bin/env node +// Watch the latest Release workflow run, then verify local == npm == tag. +// Run this after merging a release PR. +// +// Usage: +// pnpm release:watch # find latest run, watch it, verify +// pnpm release:watch --no-pull # skip "git pull" at the end + +import { execSync, spawnSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; + +const args = new Set(process.argv.slice(2)); +const PULL = !args.has('--no-pull'); + +function sh(cmd) { + return execSync(cmd, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); +} + +function shOk(cmd, opts = {}) { + const r = spawnSync('sh', ['-c', cmd], { stdio: opts.silent ? 'pipe' : 'inherit' }); + if (r.status !== 0) { + if (opts.silent) console.error(r.stderr?.toString() ?? ''); + process.exit(r.status ?? 1); + } +} + +function findLatestRun({ waitForNew = false, sinceIso } = {}) { + const deadline = Date.now() + 90_000; // 90s window for a new run to appear + while (true) { + const out = sh( + `gh run list --workflow=release.yml --limit=5 --json databaseId,status,conclusion,headSha,createdAt,displayTitle` + ); + const runs = JSON.parse(out); + if (runs.length > 0) { + if (waitForNew && sinceIso) { + const newer = runs.find((r) => r.createdAt > sinceIso); + if (newer) return newer; + } else { + return runs[0]; + } + } + if (!waitForNew || Date.now() > deadline) return runs[0] ?? null; + process.stdout.write('.'); + execSync('sleep 3'); + } +} + +console.log('\n=== alpha-loop release:watch ===\n'); + +const sinceIso = new Date(Date.now() - 5 * 60_000).toISOString(); +console.log('Locating latest release workflow run...'); +const run = findLatestRun({ waitForNew: true, sinceIso }); +if (!run) { + console.error('No release workflow runs found. Did you merge a release PR?'); + process.exit(1); +} + +console.log(`\nRun #${run.databaseId} — ${run.displayTitle}`); +console.log(` status: ${run.status} sha: ${run.headSha.slice(0, 8)}`); +console.log(` created: ${run.createdAt}`); +console.log(''); + +if (run.status !== 'completed') { + console.log('Streaming live status (gh run watch)...\n'); + shOk(`gh run watch ${run.databaseId} --exit-status`); +} + +// Re-fetch to get final conclusion +const finalRaw = sh(`gh run view ${run.databaseId} --json conclusion,status`); +const final = JSON.parse(finalRaw); +if (final.conclusion !== 'success') { + console.error(`\n✗ Release workflow ended with conclusion: ${final.conclusion}`); + console.error(` View: gh run view ${run.databaseId} --log-failed`); + process.exit(1); +} + +console.log('\n✓ Release workflow completed successfully.\n'); + +// Sync local state +if (PULL) { + console.log('Syncing local master...'); + const branch = sh('git rev-parse --abbrev-ref HEAD'); + if (branch === 'master') { + shOk('git pull --ff-only origin master'); + } else { + console.log(` (on ${branch}, not pulling — pass --no-pull to silence this)`); + } +} + +// Verify versions +console.log('\nVerifying version sync:'); +const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8')); +const localVer = pkg.version; +let tagVer = ''; +try { tagVer = sh('git describe --tags --abbrev=0').replace(/^v/, ''); } catch { /* no tags */ } +let npmVer = ''; +try { npmVer = sh(`npm view ${pkg.name} version`); } catch { /* unpublished */ } + +const ok = localVer && localVer === tagVer && localVer === npmVer; +const mark = (a, b) => (a === b ? '✓' : '✗'); +console.log(` local: ${localVer}`); +console.log(` tag: ${tagVer} ${mark(localVer, tagVer)}`); +console.log(` npm: ${npmVer} ${mark(localVer, npmVer)}`); +console.log(''); + +if (!ok) { + console.error('✗ Versions are out of sync. Investigate before next release.'); + process.exit(1); +} + +console.log(`✓ All in sync at v${localVer}.\n`); From ebf1f5d6c4e99dde58e3b3e459113da72f5fe038 Mon Sep 17 00:00:00 2001 From: Bradley Taylor Date: Tue, 26 May 2026 19:45:40 -0700 Subject: [PATCH 3/3] chore(release): release v2.0.5 - c869883 ci(release): switch to bump-in-PR deploy flow - 9864509 fix(config): default project to 0 so removing the YAML key disables the board - aacb191 chore(release): bump version to v2.0.4 (#278) - f725af8 chore(release): bump version to v2.0.3 (#275) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index bd64292..82c87dc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bradtaylorsf/alpha-loop", - "version": "2.0.4", + "version": "2.0.5", "description": "Agent-agnostic automated development loop: Plan → Build → Test → Review → Ship", "type": "module", "packageManager": "pnpm@9.0.0",