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
25 changes: 25 additions & 0 deletions .changeset/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Changesets (AI-authored)

This folder holds a **single pending release intent** per PR, in Changesets
frontmatter format:

```md
---
"@legioncodeinc/cli-kit": patch
---

A one-line, user-facing summary of the change.
```

Unlike vanilla Changesets, the entry here is normally written **for you** by
`scripts/release/ai-changeset.mjs` (Claude Sonnet 5 on Amazon Bedrock) when a PR
opens — see `.github/workflows/release-gate.yaml` and `RELEASE-AUTOMATION.md`.

- **patch** → auto-approved; the version is bumped on the PR branch immediately.
- **minor** → held until GitHub user **@thenotoriousllama** comments
`Approved Release` (case-insensitive) on the PR.
- **major** → blocked; cut a major release manually.

You may still write the file by hand — if a changeset already exists, the AI
step leaves it alone. Add the `no-changeset` label to a PR to skip release
automation entirely. Only `README.md` in this folder is ignored by the tooling.
5 changes: 5 additions & 0 deletions .changeset/ai-7ea1268.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@legioncodeinc/cli-kit": minor
---

Initial release of @legioncodeinc/cli-kit, a zero-dependency ESM toolkit providing shared CLI mechanisms: color output, Windows-safe shutdown handling, exit codes, argument parsing, telemetry opt-out detection, and usage formatting.
61 changes: 61 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
name: CI

# The quality gate on every push to main and every PR into main for
# @legioncodeinc/cli-kit. Runs typecheck + test + build — the SAME recipe a
# developer runs locally so a green local gate predicts a green CI.
#
# WHY THREE OPERATING SYSTEMS: the shutdown module calls process._getActiveHandles()
# and manipulates the event loop, and the color module checks stream.isTTY —
# behavior that can vary subtly across platforms. Running on ubuntu + macos +
# windows exercises that per-OS logic on each real OS.
#
# Node is pinned to the package's engine floor (22.x).

on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch: {}

concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
gate:
name: cli-kit gate (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@v4.2.2
with:
persist-credentials: false

- name: Setup Node
uses: actions/setup-node@v6.4.0
with:
node-version: '22.x'
cache: npm

- name: Install (npm ci)
run: npm ci

- name: Typecheck
run: npm run typecheck

- name: Test
run: npm run test

- name: Build (tsc)
run: npm run build

- name: Pack sanity (npm pack --dry-run)
run: npm pack --dry-run
46 changes: 46 additions & 0 deletions .github/workflows/release-approve.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
name: Release approve

# The human gate for MINOR releases. When GitHub user @thenotoriousllama
# comments "Approved Release" (case-insensitive) on a PR, add the
# `release-approved` label. release-gate.yaml then performs the minor bump.
#
# SECURITY: `issue_comment` is a PRIVILEGED trigger. This workflow does NOT
# check out the PR or run ANY repo code — it only calls the GitHub API to add
# a label and post an ack.

on:
issue_comment:
types: [created, edited]

concurrency:
group: release-approve-${{ github.event.issue.number }}
cancel-in-progress: false

permissions:
contents: read

env:
APPROVER: thenotoriousllama

jobs:
approve:
if: ${{ github.event.issue.pull_request }}
runs-on: ubuntu-latest
steps:
- name: Verify approver + phrase, then label
uses: actions/github-script@v7.0.1
with:
github-token: ${{ secrets.RELEASE_PAT }}
script: |
const login = (context.payload.comment.user.login || '').toLowerCase();
const body = context.payload.comment.body || '';
if (login !== process.env.APPROVER.toLowerCase()) return;
if (!/approved release/i.test(body)) return;
await github.rest.issues.addLabels({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: context.payload.issue.number,
labels: ['release-approved'] });
await github.rest.issues.createComment({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: context.payload.issue.number,
body: '✅ Approved — applying the minor bump. The release-gate check will go green; it publishes on merge.' });
220 changes: 220 additions & 0 deletions .github/workflows/release-gate.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
name: Release gate

# PR-time release automation. On every PR into main, Claude Sonnet 5 (on Amazon
# Bedrock) picks a semver bump and writes a changeset, then this workflow drives
# a single required status check, `release-gate`:
#
# patch -> auto-approved: bump the version ON THE PR BRANCH now; gate = success.
# minor -> gate = pending until the `release-approved` label is present, then
# bump and flip the gate to success. The label is added by
# release-approve.yaml when @thenotoriousllama comments
# "Approved Release" (case-insensitive).
# major -> gate = failure (blocked). Cut a major release manually.
#
# The bump lives on the PR branch, so when the PR merges, main already carries
# the new package.json version; tag-on-merge.yaml then tags it and release.yaml
# publishes.
#
# SECURITY: this workflow is `pull_request`-triggered ONLY — never issue_comment
# / pull_request_target / workflow_run — so it never checks out or runs PR code
# in a privileged (secret-bearing, write-token) context.

on:
pull_request:
types: [opened, synchronize, reopened, labeled, unlabeled]

concurrency:
group: release-gate-${{ github.event.pull_request.number }}
cancel-in-progress: false

permissions:
contents: write
pull-requests: write
statuses: write

env:
APPROVER: thenotoriousllama
GATE_CONTEXT: release-gate

jobs:
skip:
if: >-
github.event.pull_request.head.repo.full_name == github.repository
&& contains(github.event.pull_request.labels.*.name, 'no-changeset')
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v7.0.1
with:
script: |
await github.rest.repos.createCommitStatus({
owner: context.repo.owner, repo: context.repo.repo,
sha: context.payload.pull_request.head.sha,
context: process.env.GATE_CONTEXT, state: 'success',
description: 'Release skipped (no-changeset label).' });

evaluate:
if: >-
github.event.pull_request.head.repo.full_name == github.repository
&& !contains(github.event.pull_request.labels.*.name, 'no-changeset')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4.2.2
with:
ref: ${{ github.event.pull_request.head.ref }}
fetch-depth: 0
persist-credentials: true

- uses: actions/setup-node@v6.4.0
with:
node-version: '22.x'

- name: Install Bedrock SDK (ephemeral)
run: npm i --no-save --ignore-scripts @aws-sdk/client-bedrock-runtime

- name: Configure git identity
run: |
git config user.name "cli-kit-release-bot"
git config user.email "release-bot@users.noreply.github.com"

- name: Detect prior bump on this PR
id: state
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
set -euo pipefail
HEAD_VER="$(node -p "require('./package.json').version")"
git show "${BASE_SHA}:package.json" > .base-package.json 2>/dev/null || true
BASE_VER="$(node -p "require('./.base-package.json').version" 2>/dev/null || echo '')"
rm -f .base-package.json
if [ -n "$BASE_VER" ] && [ "$BASE_VER" != "$HEAD_VER" ]; then
echo "already_bumped=true" >> "$GITHUB_OUTPUT"
else
echo "already_bumped=false" >> "$GITHUB_OUTPUT"
fi

- name: Detect docs-only PR
id: docs
if: steps.state.outputs.already_bumped != 'true'
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
FILES="$(git diff --name-only "${BASE_SHA}...${HEAD_SHA}" -- . ':(exclude).changeset')"
echo "Changed files (excluding .changeset):"
echo "$FILES"
DOCS_ONLY=true
if [ -z "$FILES" ]; then
DOCS_ONLY=false
else
while IFS= read -r f; do
[ -z "$f" ] && continue
case "$f" in
*.md | *.mdx | *.markdown) : ;;
*) DOCS_ONLY=false ;;
esac
done <<< "$FILES"
fi
echo "docs_only=$DOCS_ONLY" >> "$GITHUB_OUTPUT"

- name: Gate docs-only PR
if: steps.state.outputs.already_bumped != 'true' && steps.docs.outputs.docs_only == 'true'
uses: actions/github-script@v7.0.1
with:
script: |
await github.rest.repos.createCommitStatus({
owner: context.repo.owner, repo: context.repo.repo,
sha: context.payload.pull_request.head.sha,
context: process.env.GATE_CONTEXT, state: 'success',
description: 'Docs-only (markdown) — no release.' });

- name: AI changeset (Sonnet 5 on Bedrock)
id: ai
if: steps.state.outputs.already_bumped != 'true' && steps.docs.outputs.docs_only != 'true'
env:
AWS_BEARER_TOKEN_BEDROCK: ${{ secrets.AWS_BEDROCK_API_KEY }}
AWS_REGION: ${{ vars.AWS_REGION }}
BEDROCK_MODEL_ID: ${{ vars.BEDROCK_MODEL_ID }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: node scripts/release/ai-changeset.mjs

- name: Gate already-bumped PR
if: steps.state.outputs.already_bumped == 'true'
uses: actions/github-script@v7.0.1
with:
script: |
await github.rest.repos.createCommitStatus({
owner: context.repo.owner, repo: context.repo.repo,
sha: context.payload.pull_request.head.sha,
context: process.env.GATE_CONTEXT, state: 'success',
description: 'Version already bumped on this PR.' });

- name: Block major
if: steps.state.outputs.already_bumped != 'true' && steps.docs.outputs.docs_only != 'true' && steps.ai.outputs.bump == 'major'
uses: actions/github-script@v7.0.1
with:
script: |
await github.rest.repos.createCommitStatus({
owner: context.repo.owner, repo: context.repo.repo,
sha: context.payload.pull_request.head.sha,
context: process.env.GATE_CONTEXT, state: 'failure',
description: 'Major (breaking) is blocked — cut a major release manually.' });
await github.rest.issues.addLabels({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
labels: ['needs-manual-release'] });
core.setFailed('Major change blocked (see the needs-manual-release label).');

- name: Apply bump / stage changeset
id: mut
if: steps.state.outputs.already_bumped != 'true' && steps.docs.outputs.docs_only != 'true' && steps.ai.outputs.bump != 'major'
env:
BUMP: ${{ steps.ai.outputs.bump }}
BRANCH: ${{ github.event.pull_request.head.ref }}
APPROVER: ${{ env.APPROVER }}
APPROVED: ${{ contains(github.event.pull_request.labels.*.name, 'release-approved') }}
run: |
set -euo pipefail
STATE=success
DESC="No release changeset — nothing to publish."
if [ "$BUMP" = "patch" ]; then
node scripts/release/apply-bump.mjs
git commit --no-verify -m "chore(release): patch bump [ai]"
git push origin HEAD:"$BRANCH"
DESC="Patch — auto-approved."
elif [ "$BUMP" = "minor" ]; then
if [ "$APPROVED" = "true" ]; then
node scripts/release/apply-bump.mjs
git commit --no-verify -m "chore(release): minor bump [approved]"
git push origin HEAD:"$BRANCH"
DESC="Minor — approved."
else
git add .changeset
git commit --no-verify -m "chore(release): add changeset [ai] (minor)" || echo "changeset already committed"
git push origin HEAD:"$BRANCH"
STATE=pending
DESC="Minor — comment 'Approved Release' as @${APPROVER} to release."
fi
fi
{
echo "final_sha=$(git rev-parse HEAD)"
echo "state=$STATE"
echo "desc=$DESC"
} >> "$GITHUB_OUTPUT"

- name: Set release-gate status
if: steps.state.outputs.already_bumped != 'true' && steps.docs.outputs.docs_only != 'true' && steps.ai.outputs.bump != 'major'
env:
FINAL_SHA: ${{ steps.mut.outputs.final_sha }}
GATE_STATE: ${{ steps.mut.outputs.state }}
GATE_DESC: ${{ steps.mut.outputs.desc }}
uses: actions/github-script@v7.0.1
with:
script: |
await github.rest.repos.createCommitStatus({
owner: context.repo.owner, repo: context.repo.repo,
sha: process.env.FINAL_SHA,
context: process.env.GATE_CONTEXT,
state: process.env.GATE_STATE,
description: (process.env.GATE_DESC || '').slice(0, 140) });
Loading
Loading