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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .alpha-loop.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 ==================================================================
Expand Down
152 changes: 25 additions & 127 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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",
Expand Down
145 changes: 145 additions & 0 deletions scripts/deploy.mjs
Original file line number Diff line number Diff line change
@@ -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`);
Loading
Loading