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
This commit is contained in:
catlog22
2026-02-27 19:06:34 +08:00
parent 47fe0d3bec
commit 61f929005c
15 changed files with 344 additions and 2 deletions

147
.github/workflows/release-canary.yml vendored Normal file
View 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
View 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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -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;

View File

@@ -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
View 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}`);
}
}