fix(ci): add GitHub Actions release workflows and fix visual test
- Add release.yml for manual npm publishing on GitHub Release - Add release-canary.yml for automated canary releases (every 20 commits) - Fix visual test template path (use homedir() instead of ~) - Update visual test baselines - Add sync-version.mjs script for version synchronization - Add sync-version npm script to package.json
147
.github/workflows/release-canary.yml
vendored
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
name: Release Canary
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths-ignore:
|
||||||
|
- '**.md'
|
||||||
|
- 'docs/**'
|
||||||
|
- '.claude/**'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
commit_threshold:
|
||||||
|
description: 'Commits needed for canary release'
|
||||||
|
required: false
|
||||||
|
default: '20'
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
COMMIT_THRESHOLD: ${{ github.event.inputs.commit_threshold || '20' }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# 检查是否需要发布预览版
|
||||||
|
check-release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
should_release: ${{ steps.check.outputs.should_release }}
|
||||||
|
commit_count: ${{ steps.check.outputs.commit_count }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 100
|
||||||
|
|
||||||
|
- name: Check commit count
|
||||||
|
id: check
|
||||||
|
run: |
|
||||||
|
# Get last release tag (not canary)
|
||||||
|
LAST_TAG=$(git tag -l 'v*' | grep -v canary | sort -V | tail -1 || echo "")
|
||||||
|
|
||||||
|
if [ -z "$LAST_TAG" ]; then
|
||||||
|
# No release tags, count all commits
|
||||||
|
COMMIT_COUNT=$(git rev-list --count HEAD)
|
||||||
|
else
|
||||||
|
# Count commits since last release
|
||||||
|
COMMIT_COUNT=$(git rev-list --count ${LAST_TAG}..HEAD)
|
||||||
|
fi
|
||||||
|
|
||||||
|
THRESHOLD=${{ env.COMMIT_THRESHOLD }}
|
||||||
|
|
||||||
|
echo "📊 Commits since last release: ${COMMIT_COUNT}"
|
||||||
|
echo "🎯 Threshold: ${THRESHOLD}"
|
||||||
|
echo "commit_count=${COMMIT_COUNT}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
# Release canary when threshold reached
|
||||||
|
if [ "$COMMIT_COUNT" -ge "$THRESHOLD" ]; then
|
||||||
|
echo "✅ Reached ${THRESHOLD} commits threshold, will release canary"
|
||||||
|
echo "should_release=true" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
REMAINING=$((THRESHOLD - COMMIT_COUNT))
|
||||||
|
echo "⏭️ ${REMAINING} more commits needed for next canary"
|
||||||
|
echo "should_release=false" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 发布预览版
|
||||||
|
canary:
|
||||||
|
needs: check-release
|
||||||
|
if: needs.check-release.outputs.should_release == 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 100
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
registry-url: 'https://registry.npmjs.org'
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: npm test
|
||||||
|
continue-on-error: false
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Generate canary version
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
CURRENT_VERSION=$(node -p "require('./package.json').version")
|
||||||
|
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||||
|
TIMESTAMP=$(git log -1 --format=%cd --date=format:%Y%m%d%H%M%S)
|
||||||
|
|
||||||
|
# Canary version: 6.3.54-canary.20260227123456.abc1234
|
||||||
|
CANARY_VERSION="${CURRENT_VERSION}-canary.${TIMESTAMP}.${SHORT_SHA}"
|
||||||
|
|
||||||
|
echo "canary_version=${CANARY_VERSION}" >> $GITHUB_OUTPUT
|
||||||
|
echo "📦 Canary version: ${CANARY_VERSION}"
|
||||||
|
|
||||||
|
npm version ${CANARY_VERSION} --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
|
- name: Run prepublish checks
|
||||||
|
run: node ccw/scripts/prepublish-clean.mjs
|
||||||
|
|
||||||
|
- name: Publish canary to npm
|
||||||
|
run: npm publish --tag canary --provenance --access public
|
||||||
|
env:
|
||||||
|
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
|
|
||||||
|
- name: Summary
|
||||||
|
run: |
|
||||||
|
echo "### 🚀 Canary Release Published" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- **Version**: \`${{ steps.version.outputs.canary_version }}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- **Commits**: ${{ needs.check-release.outputs.commit_count }} since last release" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- **Install**: \`npm install claude-code-workflow@canary\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
# 跳过时的通知
|
||||||
|
skip-notice:
|
||||||
|
needs: check-release
|
||||||
|
if: needs.check-release.outputs.should_release == 'false'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Skip notice
|
||||||
|
run: |
|
||||||
|
COMMIT_COUNT=${{ needs.check-release.outputs.commit_count }}
|
||||||
|
THRESHOLD=${{ env.COMMIT_THRESHOLD }}
|
||||||
|
REMAINING=$((THRESHOLD - COMMIT_COUNT))
|
||||||
|
|
||||||
|
echo "### ⏭️ Canary Release Skipped" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- **Commits**: ${COMMIT_COUNT}/${THRESHOLD}" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- **Remaining**: ${REMAINING} more commits needed" >> $GITHUB_STEP_SUMMARY
|
||||||
79
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: 'Tag to release (e.g., v6.3.55)'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
skip_tests:
|
||||||
|
description: 'Skip tests (not recommended)'
|
||||||
|
required: false
|
||||||
|
default: 'false'
|
||||||
|
type: boolean
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.inputs.tag || github.ref }}
|
||||||
|
fetch-depth: 100
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
registry-url: 'https://registry.npmjs.org'
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
if: github.event.inputs.skip_tests != 'true'
|
||||||
|
run: npm test
|
||||||
|
continue-on-error: false
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Run prepublish checks
|
||||||
|
run: node ccw/scripts/prepublish-clean.mjs
|
||||||
|
|
||||||
|
- name: Dry run publish
|
||||||
|
run: npm publish --dry-run
|
||||||
|
|
||||||
|
- name: Publish to npm
|
||||||
|
run: npm publish --provenance --access public
|
||||||
|
env:
|
||||||
|
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
|
|
||||||
|
- name: Create GitHub Release Notes
|
||||||
|
if: github.event_name == 'workflow_dispatch'
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
tag_name: ${{ github.event.inputs.tag }}
|
||||||
|
generate_release_notes: true
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Summary
|
||||||
|
run: |
|
||||||
|
VERSION=${{ github.event.inputs.tag || github.ref_name }}
|
||||||
|
echo "### 🚀 Release Published" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- **Version**: \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- **Registry**: npm (latest)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- **Install**: \`npm install claude-code-workflow@latest\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 93 KiB |
@@ -2,7 +2,7 @@ import { after, before, describe, it } from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { createServer } from 'node:http';
|
import { createServer } from 'node:http';
|
||||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
import { tmpdir } from 'node:os';
|
import { homedir, tmpdir } from 'node:os';
|
||||||
import { basename, join, resolve, sep } from 'node:path';
|
import { basename, join, resolve, sep } from 'node:path';
|
||||||
|
|
||||||
import { chromium } from 'playwright';
|
import { chromium } from 'playwright';
|
||||||
@@ -150,7 +150,7 @@ function writePrototypeHtml(filePath: string, target: string, style: number, lay
|
|||||||
|
|
||||||
describe('ui_generate_preview visual regression', () => {
|
describe('ui_generate_preview visual regression', () => {
|
||||||
const prototypesDir = mkdtempSync(join(tmpdir(), 'ccw-ui-generate-preview-'));
|
const prototypesDir = mkdtempSync(join(tmpdir(), 'ccw-ui-generate-preview-'));
|
||||||
const templatePath = resolve(process.cwd(), '~/.ccw/workflows/_template-compare-matrix.html');
|
const templatePath = resolve(homedir(), '.ccw/workflows/_template-compare-matrix.html');
|
||||||
|
|
||||||
let server: StaticServer | undefined;
|
let server: StaticServer | undefined;
|
||||||
let browser: import('playwright').Browser | undefined;
|
let browser: import('playwright').Browser | undefined;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"ccw/frontend"
|
"ccw/frontend"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"sync-version": "node scripts/sync-version.mjs",
|
||||||
"build": "tsc -p ccw/tsconfig.json",
|
"build": "tsc -p ccw/tsconfig.json",
|
||||||
"postbuild": "node -e \"const fs=require('fs');['ccw/bin/ccw.js','ccw/bin/ccw-mcp.js'].forEach(f=>{try{fs.chmodSync(f,0o755)}catch{}})\"",
|
"postbuild": "node -e \"const fs=require('fs');['ccw/bin/ccw.js','ccw/bin/ccw-mcp.js'].forEach(f=>{try{fs.chmodSync(f,0o755)}catch{}})\"",
|
||||||
"start": "node ccw/bin/ccw.js",
|
"start": "node ccw/bin/ccw.js",
|
||||||
|
|||||||
115
scripts/sync-version.mjs
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* 版本同步脚本
|
||||||
|
* 用法:
|
||||||
|
* node scripts/sync-version.mjs # 检查版本状态
|
||||||
|
* node scripts/sync-version.mjs --sync # 同步到最新 npm 版本
|
||||||
|
* node scripts/sync-version.mjs --tag # 创建对应 git tag
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import { readFileSync, writeFileSync } from 'fs';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { dirname, join } from 'path';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const rootDir = join(__dirname, '..');
|
||||||
|
const packagePath = join(rootDir, 'package.json');
|
||||||
|
|
||||||
|
function run(cmd, silent = false) {
|
||||||
|
try {
|
||||||
|
return execSync(cmd, { cwd: rootDir, encoding: 'utf-8', stdio: silent ? 'pipe' : 'inherit' });
|
||||||
|
} catch (e) {
|
||||||
|
if (!silent) console.error(`Command failed: ${cmd}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runSilent(cmd) {
|
||||||
|
try {
|
||||||
|
return execSync(cmd, { cwd: rootDir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLocalVersion() {
|
||||||
|
const pkg = JSON.parse(readFileSync(packagePath, 'utf-8'));
|
||||||
|
return pkg.version;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNpmVersion() {
|
||||||
|
const result = runSilent('npm view claude-code-workflow version');
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLatestTag() {
|
||||||
|
const result = runSilent('git describe --tags --abbrev=0 2>/dev/null');
|
||||||
|
return result ? result.replace(/^v/, '') : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLocalVersion(version) {
|
||||||
|
const pkg = JSON.parse(readFileSync(packagePath, 'utf-8'));
|
||||||
|
pkg.version = version;
|
||||||
|
writeFileSync(packagePath, JSON.stringify(pkg, null, 2) + '\n');
|
||||||
|
console.log(`✅ Updated package.json to ${version}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTag(version) {
|
||||||
|
const tagName = `v${version}`;
|
||||||
|
run(`git tag -a ${tagName} -m "Release ${tagName}"`);
|
||||||
|
console.log(`✅ Created tag ${tagName}`);
|
||||||
|
console.log('💡 Run `git push origin ${tagName}` to push the tag');
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const shouldSync = args.includes('--sync');
|
||||||
|
const shouldTag = args.includes('--tag');
|
||||||
|
|
||||||
|
console.log('📦 Version Status\n');
|
||||||
|
|
||||||
|
const localVersion = getLocalVersion();
|
||||||
|
const npmVersion = getNpmVersion();
|
||||||
|
const tagVersion = getLatestTag();
|
||||||
|
|
||||||
|
console.log(` Local (package.json): ${localVersion}`);
|
||||||
|
console.log(` NPM (latest): ${npmVersion || 'not published'}`);
|
||||||
|
console.log(` GitHub Tag (latest): ${tagVersion || 'no tags'}`);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
const allVersions = [localVersion, npmVersion, tagVersion].filter(Boolean);
|
||||||
|
const allMatch = allVersions.every(v => v === allVersions[0]);
|
||||||
|
|
||||||
|
if (allMatch) {
|
||||||
|
console.log('✅ All versions are in sync!\n');
|
||||||
|
} else {
|
||||||
|
console.log('⚠️ Versions are out of sync!\n');
|
||||||
|
|
||||||
|
if (shouldSync && npmVersion) {
|
||||||
|
console.log(`Syncing to npm version: ${npmVersion}`);
|
||||||
|
setLocalVersion(npmVersion);
|
||||||
|
} else if (!shouldSync) {
|
||||||
|
console.log('💡 Run with --sync to sync local version to npm');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldTag && localVersion) {
|
||||||
|
const currentTag = `v${localVersion}`;
|
||||||
|
const existingTags = runSilent('git tag -l ' + currentTag);
|
||||||
|
|
||||||
|
if (existingTags) {
|
||||||
|
console.log(`⚠️ Tag ${currentTag} already exists`);
|
||||||
|
} else {
|
||||||
|
createTag(localVersion);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shouldSync && !shouldTag && !allMatch) {
|
||||||
|
console.log('Suggested actions:');
|
||||||
|
if (npmVersion && localVersion !== npmVersion) {
|
||||||
|
console.log(` node scripts/sync-version.mjs --sync # Sync to npm ${npmVersion}`);
|
||||||
|
}
|
||||||
|
if (tagVersion !== localVersion) {
|
||||||
|
console.log(` node scripts/sync-version.mjs --tag # Create tag v${localVersion}`);
|
||||||
|
}
|
||||||
|
}
|
||||||