From 9d8e2a9aab887ef1d6353ce2dd591c7064c63254 Mon Sep 17 00:00:00 2001 From: activadee Date: Sun, 4 Jan 2026 21:16:13 +0100 Subject: [PATCH 1/7] Implementation complete. Here's the summary: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## AI-Powered Changesets Workflow ### Files Created | File | Purpose | |------|---------| | actions/changeset/action.yml | Composite action that sets up OpenCode and runs the AI skill | | actions/changeset/skill.md | AI behavior instructions for generating changesets | | scripts/write-changeset.ts | OpenCode tool to write .changeset/*.md files | | src/cli/templates/changeset.ts | Workflow template for CLI installer | | .github/workflows/changeset.yml | Dogfooding workflow for this repo | ### Files Updated | File | Changes | |------|---------| | src/cli/templates/index.ts | Added changeset export and file mapping | | src/cli/installer.ts | Added CHANGESET generator | | src/cli/index.ts | Added changeset option to multiselect | | test/installer.test.js | Added 6 tests for changeset workflow | | README.md | Documented new action with usage example | | AGENTS.md | Updated structure and actions table | ### How It Works 1. **PR triggers workflow** → AI analyzes diff, commits, PR description 2. **Detects affected packages** → Parses monorepo config (pnpm/npm/yarn workspaces) 3. **Infers version bump** → From conventional commits (feat: → minor, fix: → patch) 4. **Generates changeset Attempt: att-78a8e1a2-339c-4c3e-85ef-2ae316b5e426 Profile: apg-70541e2b-d01c-4d50-a814-7025ac222ebe --- .github/workflows/changeset.yml | 25 +++ AGENTS.md | 8 +- README.md | 40 ++++- actions/changeset/action.yml | 46 ++++++ actions/changeset/skill.md | 281 ++++++++++++++++++++++++++++++++ scripts/write-changeset.ts | 125 ++++++++++++++ src/cli/index.ts | 2 + src/cli/installer.ts | 2 + src/cli/templates/changeset.ts | 27 +++ src/cli/templates/index.ts | 4 +- test/installer.test.js | 77 ++++++++- 11 files changed, 632 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/changeset.yml create mode 100644 actions/changeset/action.yml create mode 100644 actions/changeset/skill.md create mode 100644 scripts/write-changeset.ts create mode 100644 src/cli/templates/changeset.ts diff --git a/.github/workflows/changeset.yml b/.github/workflows/changeset.yml new file mode 100644 index 0000000..90daad5 --- /dev/null +++ b/.github/workflows/changeset.yml @@ -0,0 +1,25 @@ +name: AI Changeset + +on: + pull_request: + types: [opened, synchronize, reopened] + +jobs: + changeset: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.head_ref }} + token: ${{ secrets.GITHUB_TOKEN }} + + - uses: ./actions/changeset + with: + mode: commit + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + OPENCODE_AUTH: ${{ secrets.OPENCODE_AUTH }} diff --git a/AGENTS.md b/AGENTS.md index 1305955..425d6ec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,12 +21,16 @@ open-workflows/ │ ├── doc-sync/ # AI-powered │ │ ├── action.yml │ │ └── skill.md +│ ├── changeset/ # AI-powered +│ │ ├── action.yml +│ │ └── skill.md │ └── release/ # No AI - pure script │ ├── action.yml │ └── src/publish.ts ├── scripts/ # OpenCode custom tools (copied to ~/.config/opencode/tool/) │ ├── submit-review.ts # PR review tool -│ └── apply-labels.ts # Issue labeling tool +│ ├── apply-labels.ts # Issue labeling tool +│ └── write-changeset.ts # Changeset file writer ├── src/ │ └── cli/ # Workflow installer CLI │ ├── index.ts @@ -43,6 +47,7 @@ open-workflows/ | `pr-review` | Yes | AI code review, posts sticky comment | | `issue-label` | Yes | Auto-labels based on content | | `doc-sync` | Yes | Syncs docs with code changes | +| `changeset` | Yes | AI-generated changesets for monorepo releases | | `release` | No | Semantic versioning + npm publish with provenance | ## WHERE TO LOOK @@ -64,6 +69,7 @@ Tools in `scripts/` are copied to `~/.config/opencode/tool/` at runtime. |--------|---------| | `submit-review.ts` | Post/update sticky PR comment | | `apply-labels.ts` | Create + apply labels | +| `write-changeset.ts` | Write changeset files to `.changeset/` | ## RELEASE SCRIPT diff --git a/README.md b/README.md index 70b8b07..0d4c479 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ The CLI will prompt you to select workflows and install them to `.github/workflo | `pr-review` | Yes | AI-powered code reviews | | `issue-label` | Yes | Auto-label issues based on content | | `doc-sync` | Yes | Keep docs in sync with code changes | +| `changeset` | Yes | AI-generated changesets for monorepo releases | | `release` | No | Semantic versioning with npm provenance | ## Manual Usage @@ -72,6 +73,37 @@ jobs: No NPM_TOKEN needed - uses OIDC trusted publishing with provenance. +### Changeset Action (AI-Powered) + +```yaml +name: AI Changeset +on: + pull_request: + types: [opened, synchronize, reopened] +jobs: + changeset: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.head_ref }} + - uses: activadee/open-workflows/actions/changeset@main + with: + mode: commit # or 'comment' to suggest via PR comment + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} +``` + +The changeset action analyzes PR changes and generates [Changesets](https://github.com/changesets/changesets) files automatically: +- Detects which packages are affected in monorepos +- Infers version bump type from conventional commits +- Writes user-facing changelog entries + ## Authentication ### For AI Actions @@ -107,12 +139,18 @@ OPTIONS ## How It Works -**AI Actions (pr-review, issue-label, doc-sync):** +**AI Actions (pr-review, issue-label, doc-sync, changeset):** 1. Workflow triggers on GitHub event 2. Composite action sets up Bun and OpenCode 3. OpenCode runs with the bundled skill 4. AI analyzes content and takes action +**Changeset Action:** +1. Triggers on PR open/update +2. AI analyzes diff, commits, and PR description +3. Generates `.changeset/.md` with package bumps and changelog +4. Either commits to PR branch or suggests via comment + **Release Action:** 1. Manually triggered with version bump type 2. Generates changelog from git commits diff --git a/actions/changeset/action.yml b/actions/changeset/action.yml new file mode 100644 index 0000000..4b003f5 --- /dev/null +++ b/actions/changeset/action.yml @@ -0,0 +1,46 @@ +name: 'AI Changeset' +description: 'AI-powered changeset generation for monorepo releases' +author: 'activadee' + +inputs: + model: + description: 'Model to use for changeset generation' + required: false + default: 'anthropic/claude-sonnet-4-5' + mode: + description: 'Operation mode: commit (auto-commit changeset) or comment (suggest via PR comment)' + required: false + default: 'commit' + +runs: + using: 'composite' + steps: + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Setup OpenCode auth + if: env.OPENCODE_AUTH != '' + shell: bash + run: | + mkdir -p ~/.local/share/opencode + echo "$OPENCODE_AUTH" > ~/.local/share/opencode/auth.json + - name: Install opencode-ai + shell: bash + run: | + curl -fsSL https://opencode.ai/install | bash + - name: Install skill and tools + shell: bash + run: | + mkdir -p .opencode/skill/changeset + cp "${{ github.action_path }}/skill.md" .opencode/skill/changeset/SKILL.md + mkdir -p ~/.config/opencode/tool + cp "${{ github.action_path }}/../../scripts/"*.ts ~/.config/opencode/tool/ + cd ~/.config/opencode && bun add @opencode-ai/plugin + + - name: Generate Changeset + shell: bash + run: | + opencode run --model "${{ inputs.model }}" \ + "Load the changeset skill. Generate changeset for PR ${{ github.event.pull_request.number }} in mode: ${{ inputs.mode }}" + env: + GITHUB_TOKEN: ${{ github.token }} diff --git a/actions/changeset/skill.md b/actions/changeset/skill.md new file mode 100644 index 0000000..3c54b7d --- /dev/null +++ b/actions/changeset/skill.md @@ -0,0 +1,281 @@ +--- +name: changeset +description: AI-powered changeset generation for monorepo releases. Analyzes PR changes to generate properly formatted changeset files. +license: MIT +--- + +## What I Do + +Analyze pull request changes and generate [Changesets](https://github.com/changesets/changesets) files that capture: +1. Which packages are affected +2. What version bump is appropriate (patch/minor/major) +3. A user-facing changelog entry + +## Workflow + +1. **Get PR context**: Fetch PR metadata and changed files + ```bash + gh pr view --json files,title,body,commits,headRefOid + ``` + +2. **Detect monorepo structure**: Find package locations + ```bash + # Check for common monorepo patterns + ls packages/ 2>/dev/null || ls apps/ 2>/dev/null || cat pnpm-workspace.yaml 2>/dev/null + ``` + +3. **Analyze changes**: For each changed file, determine: + - Which package it belongs to + - The nature of the change (feature, fix, breaking, docs, etc.) + +4. **Infer version bump**: Based on changes and commit messages + - `major`: Breaking changes (look for BREAKING CHANGE, !) + - `minor`: New features (feat:, feature) + - `patch`: Bug fixes, refactors, docs (fix:, chore:, docs:, refactor:) + +5. **Generate changeset**: Create the changeset content + +6. **Execute action**: Based on mode parameter + - `commit`: Write file and commit to PR branch + - `comment`: Post suggestion as PR comment + +## Package Detection + +### Finding Affected Packages + +1. **Parse monorepo config** (in priority order): + ```bash + # pnpm workspaces + cat pnpm-workspace.yaml + + # npm/yarn workspaces + cat package.json | jq '.workspaces' + + # lerna + cat lerna.json | jq '.packages' + ``` + +2. **Map files to packages**: + - `packages/cli/src/foo.ts` → `cli` package + - `apps/web/pages/index.tsx` → `web` package + - `libs/core/utils.ts` → `core` package + - Root-level files (README, configs) → may affect all packages or none + +3. **Read package.json for each affected package**: + ```bash + cat packages/cli/package.json | jq '.name' + ``` + +### Handling Root Changes + +Files at the repository root typically don't need changesets: +- `.github/*` - CI/CD changes +- `*.md` - Documentation +- `.eslintrc`, `tsconfig.json` - Config files +- `package.json` (root) - Dependency updates + +Exception: If root changes affect package behavior, include relevant packages. + +## Version Inference + +### From Conventional Commits + +Parse commit messages in the PR: +```bash +gh pr view --json commits --jq '.commits[].messageHeadline' +``` + +| Prefix | Version | Example | +|--------|---------|---------| +| `feat:` | minor | `feat: add retry logic` | +| `feat!:` | major | `feat!: change API signature` | +| `fix:` | patch | `fix: handle null case` | +| `perf:` | patch | `perf: optimize query` | +| `refactor:` | patch | `refactor: extract helper` | +| `docs:` | patch | `docs: update README` | +| `chore:` | patch | `chore: update deps` | +| `BREAKING CHANGE` | major | (in commit body) | + +### From PR Content + +If no conventional commits, analyze: +- PR title for keywords (add, fix, change, remove, break) +- PR body for context +- File changes (new files = likely feature, modified = likely fix) + +### Default Behavior + +When uncertain: +- Default to `patch` for single-package changes +- Ask via comment if breaking changes are detected but unclear + +## Changeset Format + +Changesets use this format in `.changeset/.md`: + +```markdown +--- +"@myorg/cli": minor +"@myorg/core": patch +--- + +Add retry logic for failed API requests + +The CLI now automatically retries failed requests up to 3 times with exponential backoff. +``` + +### Formatting Rules + +1. **YAML frontmatter**: Package names in quotes, version bump type +2. **Summary line**: Imperative mood, user-facing (what changed, not how) +3. **Optional body**: Additional context, migration notes for breaking changes +4. **Length**: Summary under 80 chars, body can be multiple paragraphs + +### Good vs Bad Summaries + +| Bad (dev-speak) | Good (user-facing) | +|-----------------|-------------------| +| `refactored auth module` | `Improved authentication reliability` | +| `fixed bug in parser` | `Fixed parsing of nested arrays` | +| `added new function` | `Add support for custom themes` | +| `updated dependencies` | `Security updates for dependencies` | + +## Writing the Changeset + +Use the `write-changeset` tool to create the changeset file: + +### Tool Arguments + +| Argument | Required | Description | +|----------|----------|-------------| +| `packages` | Yes | Object mapping package names to bump types | +| `summary` | Yes | Short summary (imperative mood, user-facing) | +| `body` | No | Additional details or migration notes | + +### Example + +```json +{ + "packages": { + "@myorg/cli": "minor", + "@myorg/core": "patch" + }, + "summary": "Add retry logic for failed API requests", + "body": "The CLI now automatically retries failed requests up to 3 times with exponential backoff." +} +``` + +## Mode: commit + +When mode is `commit`: + +1. Generate changeset using `write-changeset` tool +2. Stage the new file: + ```bash + git add .changeset/*.md + ``` +3. Commit with a descriptive message: + ```bash + git commit -m "chore: add changeset for PR #" + ``` +4. Push to PR branch: + ```bash + git push + ``` + +## Mode: comment + +When mode is `comment`: + +1. Generate the changeset content +2. Post as PR comment with the suggested changeset: + + ````markdown + ## Suggested Changeset + + Based on the changes in this PR, here's the recommended changeset: + + ```markdown + --- + "@myorg/cli": minor + --- + + Add retry logic for failed API requests + ``` + +
+ How to apply + + Create this file as `.changeset/.md` and commit it to this PR. + + Or run: `npx changeset add` and follow the prompts. +
+ ```` + +## Edge Cases + +### No Package Changes + +If changes only affect non-package files (CI, docs, root configs): +- In `commit` mode: Skip creating changeset, post comment explaining why +- In `comment` mode: Explain that no changeset is needed + +### Multiple Packages, Same Change + +When a change spans packages but represents one logical feature: +- Create ONE changeset file +- List all affected packages with appropriate bumps +- Write a unified summary + +### Existing Changeset + +Before creating a new changeset: +```bash +ls .changeset/*.md 2>/dev/null | head -5 +``` + +If changesets already exist for this PR: +- Check if they cover the current changes +- Only add a new one if there are uncovered changes +- Never overwrite existing changesets + +## Common Mistakes to Avoid + +- Do NOT create changesets for documentation-only changes in most cases +- Do NOT guess package names - always verify from package.json +- Do NOT create empty changesets +- Do NOT overwrite existing changesets from previous commits +- Do NOT commit in `comment` mode +- Do NOT assume monorepo structure - verify it exists first + +## Non-Monorepo Fallback + +If no monorepo structure is detected (single package.json at root): +- Use the package name from root package.json +- Follow the same version inference rules +- Changeset format remains the same + +## Example Changeset Output + +For a PR that adds a feature to the CLI and fixes a related bug in core: + +```markdown +--- +"@myorg/cli": minor +"@myorg/core": patch +--- + +Add support for custom retry strategies + +Users can now configure custom retry strategies for API requests. The default exponential backoff behavior is unchanged. + +``` + +## Checklist Before Submitting + +- [ ] Verified package names from actual package.json files +- [ ] Version bump matches the change type +- [ ] Summary is user-facing, not developer-focused +- [ ] Summary uses imperative mood ("Add" not "Added") +- [ ] No duplicate changesets for the same changes +- [ ] File committed only in `commit` mode diff --git a/scripts/write-changeset.ts b/scripts/write-changeset.ts new file mode 100644 index 0000000..d8c1218 --- /dev/null +++ b/scripts/write-changeset.ts @@ -0,0 +1,125 @@ +import { tool } from "@opencode-ai/plugin" +import * as fs from 'fs'; +import * as path from 'path'; +import * as crypto from 'crypto'; + +const z = tool.schema; + +type BumpType = 'major' | 'minor' | 'patch'; + +const ArgsSchema = z.object({ + packages: z.record(z.enum(['major', 'minor', 'patch'])).describe('Object mapping package names to bump types'), + summary: z.string().min(10).max(200).describe('Short summary in imperative mood (e.g., "Add retry logic")'), + body: z.string().optional().describe('Optional additional details or migration notes'), +}).superRefine((value, ctx) => { + const packageCount = Object.keys(value.packages).length; + + if (packageCount === 0) { + ctx.addIssue({ + code: 'custom', + path: ['packages'], + message: 'At least one package must be specified.', + }); + } + + const firstWord = value.summary.trim().split(' ')[0].toLowerCase(); + const pastTenseIndicators = ['added', 'fixed', 'updated', 'changed', 'removed', 'refactored']; + if (pastTenseIndicators.includes(firstWord)) { + ctx.addIssue({ + code: 'custom', + path: ['summary'], + message: `Summary should use imperative mood. Use "${firstWord.replace(/ed$/, '')}" instead of "${firstWord}".`, + }); + } +}); + +function generateChangesetId(): string { + const adjectives = ['brave', 'calm', 'dull', 'eager', 'fair', 'glad', 'happy', 'kind', 'lazy', 'neat', + 'odd', 'proud', 'quick', 'rich', 'shy', 'tall', 'warm', 'wise', 'young', 'zesty', + 'bright', 'clean', 'fresh', 'gentle', 'loud', 'mighty', 'polite', 'quiet', 'sharp', 'smooth']; + const nouns = ['apple', 'bear', 'cloud', 'door', 'eagle', 'fish', 'goat', 'horse', 'ice', 'jewel', + 'kite', 'lion', 'moon', 'nest', 'owl', 'piano', 'queen', 'river', 'star', 'tree', + 'wave', 'wolf', 'yarn', 'zebra', 'bird', 'cake', 'drum', 'frog', 'grape', 'hill']; + + const randomBytes = crypto.randomBytes(4); + const adjIndex = randomBytes[0] % adjectives.length; + const nounIndex = randomBytes[1] % nouns.length; + const number = ((randomBytes[2] << 8) | randomBytes[3]) % 1000; + + return `${adjectives[adjIndex]}-${nouns[nounIndex]}-${number}`; +} + +function buildChangesetContent(packages: Record, summary: string, body?: string): string { + let content = '---\n'; + + const sortedPackages = Object.entries(packages).sort(([a], [b]) => a.localeCompare(b)); + + for (const [packageName, bumpType] of sortedPackages) { + content += `"${packageName}": ${bumpType}\n`; + } + + content += '---\n\n'; + content += summary; + + if (body && body.trim()) { + content += '\n\n' + body.trim(); + } + + content += '\n'; + + return content; +} + +export default { + description: "Write a changeset file to .changeset/ directory. Creates the directory if it doesn't exist.", + args: ArgsSchema.shape, + async execute(args: unknown) { + const validated = ArgsSchema.parse(args); + const { packages, summary, body } = validated; + + const changesetDir = path.join(process.cwd(), '.changeset'); + + if (!fs.existsSync(changesetDir)) { + fs.mkdirSync(changesetDir, { recursive: true }); + + const configPath = path.join(changesetDir, 'config.json'); + if (!fs.existsSync(configPath)) { + const defaultConfig = { + "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json", + "changelog": "@changesets/cli/changelog", + "commit": false, + "fixed": [], + "linked": [], + "access": "restricted", + "baseBranch": "main", + "updateInternalDependencies": "patch", + "ignore": [] + }; + fs.writeFileSync(configPath, JSON.stringify(defaultConfig, null, 2) + '\n'); + } + } + + const changesetId = generateChangesetId(); + const fileName = `${changesetId}.md`; + const filePath = path.join(changesetDir, fileName); + + let attempts = 0; + let finalPath = filePath; + let finalId = changesetId; + while (fs.existsSync(finalPath) && attempts < 5) { + finalId = generateChangesetId(); + finalPath = path.join(changesetDir, `${finalId}.md`); + attempts++; + } + + const content = buildChangesetContent(packages, summary, body); + + fs.writeFileSync(finalPath, content, 'utf-8'); + + const packageList = Object.entries(packages) + .map(([name, bump]) => `${name}: ${bump}`) + .join(', '); + + return `Created changeset: .changeset/${finalId}.md\nPackages: ${packageList}\nSummary: ${summary}`; + }, +}; diff --git a/src/cli/index.ts b/src/cli/index.ts index cc0a092..517d287 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -42,6 +42,7 @@ WHAT GETS INSTALLED .github/workflows/issue-label.yml .github/workflows/doc-sync.yml .github/workflows/release.yml + .github/workflows/changeset.yml REQUIRED SECRETS For Claude Max (OAuth): @@ -67,6 +68,7 @@ const promptResults = await p.group( { value: 'label', label: 'Issue Label', hint: 'Auto-label issues' }, { value: 'doc-sync', label: 'Doc Sync', hint: 'Keep docs in sync' }, { value: 'release', label: 'Release', hint: 'Automated releases with notes' }, + { value: 'changeset', label: 'Changeset', hint: 'AI-generated changesets for monorepos' }, ], required: true, }), diff --git a/src/cli/installer.ts b/src/cli/installer.ts index dbd93ff..7020a17 100644 --- a/src/cli/installer.ts +++ b/src/cli/installer.ts @@ -5,6 +5,7 @@ import { ISSUE_LABEL, DOC_SYNC, RELEASE, + CHANGESET, WORKFLOW_FILE_MAP, type WorkflowType, } from './templates'; @@ -14,6 +15,7 @@ const WORKFLOW_GENERATORS: Record string> = { 'issue-label': ISSUE_LABEL, 'doc-sync': DOC_SYNC, release: RELEASE, + changeset: CHANGESET, }; export interface ExistingFile { diff --git a/src/cli/templates/changeset.ts b/src/cli/templates/changeset.ts new file mode 100644 index 0000000..4fa78ee --- /dev/null +++ b/src/cli/templates/changeset.ts @@ -0,0 +1,27 @@ +import { ENV_OPENCODE_AUTH, ENV_API_KEY } from './shared'; + +export const CHANGESET = (useOAuth: boolean) => `name: AI Changeset + +on: + pull_request: + types: [opened, synchronize, reopened] + +jobs: + changeset: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: \${{ github.head_ref }} + token: \${{ secrets.GITHUB_TOKEN }} + + - uses: activadee/open-workflows/actions/changeset@main + with: + mode: commit + env: + GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }}${useOAuth ? ENV_OPENCODE_AUTH : ENV_API_KEY} +`; diff --git a/src/cli/templates/index.ts b/src/cli/templates/index.ts index a29299b..5660e1e 100644 --- a/src/cli/templates/index.ts +++ b/src/cli/templates/index.ts @@ -2,12 +2,14 @@ export { PR_REVIEW } from './pr-review'; export { ISSUE_LABEL } from './issue-label'; export { DOC_SYNC } from './doc-sync'; export { RELEASE } from './release'; +export { CHANGESET } from './changeset'; -export type WorkflowType = 'review' | 'label' | 'doc-sync' | 'release'; +export type WorkflowType = 'review' | 'label' | 'doc-sync' | 'release' | 'changeset'; export const WORKFLOW_FILE_MAP: Record = { review: 'pr-review', label: 'issue-label', 'doc-sync': 'doc-sync', release: 'release', + changeset: 'changeset', }; diff --git a/test/installer.test.js b/test/installer.test.js index a6c0f1c..8d788c4 100644 --- a/test/installer.test.js +++ b/test/installer.test.js @@ -166,17 +166,90 @@ describe('installer workflow functionality', () => { it('creates all workflow types', () => { const results = installWorkflows({ - workflows: ['review', 'label', 'doc-sync', 'release'], + workflows: ['review', 'label', 'doc-sync', 'release', 'changeset'], cwd: tempDir, useOAuth: false, }); - expect(results).toHaveLength(4); + expect(results).toHaveLength(5); expect(results.every(r => r.status === 'created')).toBe(true); expect(fs.existsSync(path.join(tempDir, '.github', 'workflows', 'pr-review.yml'))).toBe(true); expect(fs.existsSync(path.join(tempDir, '.github', 'workflows', 'issue-label.yml'))).toBe(true); expect(fs.existsSync(path.join(tempDir, '.github', 'workflows', 'doc-sync.yml'))).toBe(true); expect(fs.existsSync(path.join(tempDir, '.github', 'workflows', 'release.yml'))).toBe(true); + expect(fs.existsSync(path.join(tempDir, '.github', 'workflows', 'changeset.yml'))).toBe(true); + }); + + it('creates changeset workflow file', () => { + const results = installWorkflows({ + workflows: ['changeset'], + cwd: tempDir, + useOAuth: false, + }); + + expect(results).toHaveLength(1); + expect(results[0].status).toBe('created'); + expect(results[0].name).toBe('changeset'); + expect(fs.existsSync(path.join(tempDir, '.github', 'workflows', 'changeset.yml'))).toBe(true); + }); + + it('creates changeset workflow with composite action reference', () => { + installWorkflows({ + workflows: ['changeset'], + cwd: tempDir, + useOAuth: false, + }); + + const content = fs.readFileSync( + path.join(tempDir, '.github', 'workflows', 'changeset.yml'), + 'utf-8' + ); + expect(content).toContain('activadee/open-workflows/actions/changeset@main'); + }); + + it('includes ANTHROPIC_API_KEY for changeset non-OAuth', () => { + installWorkflows({ + workflows: ['changeset'], + cwd: tempDir, + useOAuth: false, + }); + + const content = fs.readFileSync( + path.join(tempDir, '.github', 'workflows', 'changeset.yml'), + 'utf-8' + ); + expect(content).toContain('ANTHROPIC_API_KEY'); + expect(content).not.toContain('OPENCODE_AUTH'); + }); + + it('includes OPENCODE_AUTH for changeset OAuth', () => { + installWorkflows({ + workflows: ['changeset'], + cwd: tempDir, + useOAuth: true, + }); + + const content = fs.readFileSync( + path.join(tempDir, '.github', 'workflows', 'changeset.yml'), + 'utf-8' + ); + expect(content).toContain('OPENCODE_AUTH'); + expect(content).not.toContain('ANTHROPIC_API_KEY'); + }); + + it('changeset workflow has write permissions for contents', () => { + installWorkflows({ + workflows: ['changeset'], + cwd: tempDir, + useOAuth: false, + }); + + const content = fs.readFileSync( + path.join(tempDir, '.github', 'workflows', 'changeset.yml'), + 'utf-8' + ); + expect(content).toContain('contents: write'); + expect(content).toContain('pull-requests: write'); }); }); From abcbeee7ff762ac7dbb2df9c5f6f5a6846aab69f Mon Sep 17 00:00:00 2001 From: activadee Date: Sun, 4 Jan 2026 21:17:03 +0100 Subject: [PATCH 2/7] Updated actions/changeset/skill.md to include [skip ci] in the commit me 1. **Commit instruction** now shows: git commit -m "chore: add changeset for PR # [skip ci]" 2. **Added critical note** emphasizing [skip ci] is required to prevent infinite loops 3. **Added to "Common Mistakes"** section as a reminder 4. **Added to checklist** for commit mode Attempt: att-78a8e1a2-339c-4c3e-85ef-2ae316b5e426 Profile: apg-70541e2b-d01c-4d50-a814-7025ac222ebe --- actions/changeset/skill.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/actions/changeset/skill.md b/actions/changeset/skill.md index 3c54b7d..9258bb4 100644 --- a/actions/changeset/skill.md +++ b/actions/changeset/skill.md @@ -174,15 +174,17 @@ When mode is `commit`: ```bash git add .changeset/*.md ``` -3. Commit with a descriptive message: +3. Commit with `[skip ci]` to prevent infinite workflow loops: ```bash - git commit -m "chore: add changeset for PR #" + git commit -m "chore: add changeset for PR # [skip ci]" ``` 4. Push to PR branch: ```bash git push ``` +**CRITICAL**: Always include `[skip ci]` in the commit message to prevent the workflow from triggering itself. + ## Mode: comment When mode is `comment`: @@ -247,6 +249,7 @@ If changesets already exist for this PR: - Do NOT overwrite existing changesets from previous commits - Do NOT commit in `comment` mode - Do NOT assume monorepo structure - verify it exists first +- Do NOT forget `[skip ci]` in commit message - this causes infinite workflow loops ## Non-Monorepo Fallback @@ -279,3 +282,4 @@ Users can now configure custom retry strategies for API requests. The default ex - [ ] Summary uses imperative mood ("Add" not "Added") - [ ] No duplicate changesets for the same changes - [ ] File committed only in `commit` mode +- [ ] Commit message includes `[skip ci]` (commit mode only) From 57931cad78a50dfca0185b53c61cbb6899dc603f Mon Sep 17 00:00:00 2001 From: activadee Date: Sun, 4 Jan 2026 21:19:08 +0100 Subject: [PATCH 3/7] Fixed. Added token: ${{ secrets.GITHUB_TOKEN }} to the checkout step in Attempt: att-78a8e1a2-339c-4c3e-85ef-2ae316b5e426 Profile: apg-70541e2b-d01c-4d50-a814-7025ac222ebe --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 0d4c479..0175836 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,7 @@ jobs: with: fetch-depth: 0 ref: ${{ github.head_ref }} + token: ${{ secrets.GITHUB_TOKEN }} - uses: activadee/open-workflows/actions/changeset@main with: mode: commit # or 'comment' to suggest via PR comment From f213540b21a8d734736591776ed3d886255f4f46 Mon Sep 17 00:00:00 2001 From: activadee Date: Sun, 4 Jan 2026 21:23:52 +0100 Subject: [PATCH 4/7] chore: remove AI Changeset workflow file --- .github/workflows/changeset.yml | 25 ------------------------- 1 file changed, 25 deletions(-) delete mode 100644 .github/workflows/changeset.yml diff --git a/.github/workflows/changeset.yml b/.github/workflows/changeset.yml deleted file mode 100644 index 90daad5..0000000 --- a/.github/workflows/changeset.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: AI Changeset - -on: - pull_request: - types: [opened, synchronize, reopened] - -jobs: - changeset: - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - ref: ${{ github.head_ref }} - token: ${{ secrets.GITHUB_TOKEN }} - - - uses: ./actions/changeset - with: - mode: commit - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - OPENCODE_AUTH: ${{ secrets.OPENCODE_AUTH }} From 0a29d7158385b6666fcb6afada31ff43590f5845 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 4 Jan 2026 20:25:19 +0000 Subject: [PATCH 5/7] [skip ci] docs: add changeset workflow example --- examples/README.md | 41 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/examples/README.md b/examples/README.md index e0dce2f..f99634a 100644 --- a/examples/README.md +++ b/examples/README.md @@ -4,7 +4,7 @@ These examples show how to use `@activade/open-workflows` composite actions in y ## Prerequisites -For AI-powered actions (pr-review, issue-label, doc-sync), add your API key: +For AI-powered actions (pr-review, issue-label, doc-sync, changeset), add your API key: ```bash gh secret set ANTHROPIC_API_KEY @@ -102,6 +102,45 @@ jobs: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} ``` +## Changeset (AI-Powered) + +The changeset action analyzes PR changes and automatically generates changeset files for monorepo releases. + +`.github/workflows/changeset.yml`: + +```yaml +name: AI Changeset + +on: + pull_request: + types: [opened, synchronize, reopened] + +jobs: + changeset: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.head_ref }} + token: ${{ secrets.GITHUB_TOKEN }} + + - uses: activadee/open-workflows/actions/changeset@main + with: + mode: commit # or 'comment' to suggest via PR comment + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} +``` + +The action will: +- Detect affected packages in monorepos +- Infer version bump type from conventional commits +- Generate changeset files with user-facing changelog entries + ## Release (No AI required) The release action doesn't use AI - it generates changelogs from git commits and publishes with npm provenance. From 13ae628fd13469ee271fd1031d38a38588134bbd Mon Sep 17 00:00:00 2001 From: activadee Date: Sun, 4 Jan 2026 21:37:34 +0100 Subject: [PATCH 6/7] Fixed all three issues in scripts/write-changeset.ts: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. **Imperative mood regex** → Replaced with lookup map for correct suggestions: - updated → update (not updat) - changed → change (not chang) - removed → remove (not remov) 2. **Schema version** → Updated from @changesets/config@3.0.0 to @3.1.1 3. **Retry exhaustion** → Added error handling that throws if collision still exists after 5 attempts instead of silently overwriting Attempt: att-78a8e1a2-339c-4c3e-85ef-2ae316b5e426 Profile: apg-70541e2b-d01c-4d50-a814-7025ac222ebe --- scripts/write-changeset.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/scripts/write-changeset.ts b/scripts/write-changeset.ts index d8c1218..6ace2cc 100644 --- a/scripts/write-changeset.ts +++ b/scripts/write-changeset.ts @@ -23,12 +23,19 @@ const ArgsSchema = z.object({ } const firstWord = value.summary.trim().split(' ')[0].toLowerCase(); - const pastTenseIndicators = ['added', 'fixed', 'updated', 'changed', 'removed', 'refactored']; - if (pastTenseIndicators.includes(firstWord)) { + const pastTenseMap: Record = { + 'added': 'add', + 'fixed': 'fix', + 'updated': 'update', + 'changed': 'change', + 'removed': 'remove', + 'refactored': 'refactor', + }; + if (firstWord in pastTenseMap) { ctx.addIssue({ code: 'custom', path: ['summary'], - message: `Summary should use imperative mood. Use "${firstWord.replace(/ed$/, '')}" instead of "${firstWord}".`, + message: `Summary should use imperative mood. Use "${pastTenseMap[firstWord]}" instead of "${firstWord}".`, }); } }); @@ -85,7 +92,7 @@ export default { const configPath = path.join(changesetDir, 'config.json'); if (!fs.existsSync(configPath)) { const defaultConfig = { - "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json", + "$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json", "changelog": "@changesets/cli/changelog", "commit": false, "fixed": [], @@ -112,6 +119,10 @@ export default { attempts++; } + if (fs.existsSync(finalPath)) { + throw new Error(`Failed to generate unique changeset filename after ${attempts} attempts. Last attempted path: ${finalPath}`); + } + const content = buildChangesetContent(packages, summary, body); fs.writeFileSync(finalPath, content, 'utf-8'); From 146033985f4d200f4ea06d723a18119aea2e3595 Mon Sep 17 00:00:00 2001 From: activadee Date: Sun, 4 Jan 2026 21:38:26 +0100 Subject: [PATCH 7/7] Fixed. Replaced modulo-based random selection with crypto.randomInt() fo Attempt: att-78a8e1a2-339c-4c3e-85ef-2ae316b5e426 Profile: apg-70541e2b-d01c-4d50-a814-7025ac222ebe --- scripts/write-changeset.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/scripts/write-changeset.ts b/scripts/write-changeset.ts index 6ace2cc..04079c6 100644 --- a/scripts/write-changeset.ts +++ b/scripts/write-changeset.ts @@ -48,10 +48,9 @@ function generateChangesetId(): string { 'kite', 'lion', 'moon', 'nest', 'owl', 'piano', 'queen', 'river', 'star', 'tree', 'wave', 'wolf', 'yarn', 'zebra', 'bird', 'cake', 'drum', 'frog', 'grape', 'hill']; - const randomBytes = crypto.randomBytes(4); - const adjIndex = randomBytes[0] % adjectives.length; - const nounIndex = randomBytes[1] % nouns.length; - const number = ((randomBytes[2] << 8) | randomBytes[3]) % 1000; + const adjIndex = crypto.randomInt(0, adjectives.length); + const nounIndex = crypto.randomInt(0, nouns.length); + const number = crypto.randomInt(0, 1000); return `${adjectives[adjIndex]}-${nouns[nounIndex]}-${number}`; }