From 272e162952e893015ffb06bdc90f93dd4c8e58f9 Mon Sep 17 00:00:00 2001 From: Bee <68532117+abeatrix@users.noreply.github.com> Date: Tue, 9 Sep 2025 16:50:47 -0700 Subject: [PATCH] Add nightly release automation with GitHub Actions (#6041) * Add nightly release automation with GitHub Actions - Add GitHub workflow to publish nightly releases daily at 00:00 UTC - Add publish:marketplace:nightly npm script - Create publish-nightly.mjs script to handle version updates and publishing - Script converts package to "cline-nightly" with timestamp-based versioning - Publishes to both VS Code Marketplace and OpenVSX Registry * Update file name * Remove input tag * Change nightly build schedule from midnight to 4 AM PST - Change nightly build schedule from midnight to 4 AM PST - Add check to skip build if no commits in last 24 hours - Disable nightly extension in VS Code debug configs to avoid conflicts * add if: github.repository == 'cline/cline' --- .github/workflows/publish-nightly.yml | 73 +++++ .vscode/launch.json | 4 + package.json | 1 + scripts/publish-nightly.mjs | 380 ++++++++++++++++++++++++++ 4 files changed, 458 insertions(+) create mode 100644 .github/workflows/publish-nightly.yml create mode 100755 scripts/publish-nightly.mjs diff --git a/.github/workflows/publish-nightly.yml b/.github/workflows/publish-nightly.yml new file mode 100644 index 0000000000..8d33daaa53 --- /dev/null +++ b/.github/workflows/publish-nightly.yml @@ -0,0 +1,73 @@ +name: "Publish Nightly Release" + +on: + schedule: + - cron: '0 12 * * *' # 4 AM PST (UTC-8) = 12 UTC + workflow_dispatch: + +permissions: + contents: write + packages: write + checks: write + pull-requests: write + +jobs: + test: + uses: ./.github/workflows/test.yml + + publish: + needs: test + name: Publish Cline (Nightly) Extension + if: github.repository == 'cline/cline' + runs-on: ubuntu-latest + environment: publish + + steps: + - uses: actions/checkout@v4 + + - name: Check for recent commits + run: | + if [ $(git rev-list --count HEAD --since="24 hours ago") -eq 0 ]; then + echo "No commits in last 24 hours, exiting" + exit 0 + fi + echo "Found recent commits, proceeding with build" + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "lts/*" + + # Cache root dependencies - only reuse if package-lock.json exactly matches + - name: Cache root dependencies + uses: actions/cache@v4 + id: root-cache + with: + path: node_modules + key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }} + + # Cache webview-ui dependencies - only reuse if package-lock.json exactly matches + - name: Cache webview-ui dependencies + uses: actions/cache@v4 + id: webview-cache + with: + path: webview-ui/node_modules + key: ${{ runner.os }}-npm-webview-${{ hashFiles('webview-ui/package-lock.json') }} + + - name: Install root dependencies + if: steps.root-cache.outputs.cache-hit != 'true' + run: npm ci --include=optional + + - name: Install webview-ui dependencies + if: steps.webview-cache.outputs.cache-hit != 'true' + run: cd webview-ui && npm ci --include=optional + + - name: Install Publishing Tools + run: npm install -g @vscode/vsce ovsx + + - name: Publish Extension as Pre-release + env: + VSCE_PAT: ${{ secrets.VSCE_PAT }} + OVSX_PAT: ${{ secrets.OVSX_PAT }} + CLINE_ENVIRONMENT: production + run: npm run publish:marketplace:nightly \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index 1086ad7ec2..b3c9fcc499 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -12,6 +12,8 @@ "args": [ "--extensionDevelopmentPath=${workspaceFolder}", "--disable-workspace-trust", + // Avoid conflicts with the nightly extension + "--disable-extension=claude-dev.cline-nightly", "${workspaceFolder}" ], "outFiles": [ @@ -31,6 +33,7 @@ "args": [ "--extensionDevelopmentPath=${workspaceFolder}", "--disable-workspace-trust", + "--disable-extension=claude-dev.cline-nightly", "${workspaceFolder}" ], "outFiles": [ @@ -50,6 +53,7 @@ "args": [ "--extensionDevelopmentPath=${workspaceFolder}", "--disable-workspace-trust", + "--disable-extension=claude-dev.cline-nightly", "${workspaceFolder}" ], "outFiles": [ diff --git a/package.json b/package.json index a76eea5a2f..b2adc24609 100644 --- a/package.json +++ b/package.json @@ -367,6 +367,7 @@ "test:webview": "cd webview-ui && npm run test", "publish:marketplace": "vsce publish --allow-package-secrets sendgrid && ovsx publish", "publish:marketplace:prerelease": "vsce publish --allow-package-secrets sendgrid --pre-release && ovsx publish --pre-release", + "publish:marketplace:nightly": "node ./scripts/publish-nightly.mjs", "prepare": "husky", "changeset": "changeset", "version-packages": "changeset version", diff --git a/scripts/publish-nightly.mjs b/scripts/publish-nightly.mjs new file mode 100755 index 0000000000..3ed7d0ba03 --- /dev/null +++ b/scripts/publish-nightly.mjs @@ -0,0 +1,380 @@ +#!/usr/bin/env node + +/** + * Nightly publish script for VS Code extension + * Converts package.json to testing version, packages, publishes, and restores + * + * This script: + * 1. Backs up the original package.json + * 2. Updates package.json with: + * - New version (major.minor.timestamp format) + * - Changes name to "cline-nightly" + * - Changes displayName to "Cline (Nightly)" + * 3. Packages the extension as a .vsix file + * 4. Publishes to VS Code Marketplace (if VSCE_PAT is set) + * 5. Publishes to OpenVSX Registry (if OVSX_PAT is set) + * 6. Restores the original package.json + * + * Usage: + * npm run publish:marketplace:nightly + * npm run publish:marketplace:nightly -- --dry-run + * + * Environment variables: + * VSCE_PAT - Personal Access Token for VS Code Marketplace + * OVSX_PAT - Personal Access Token for OpenVSX Registry + * + * Dependencies: + * - vsce (VS Code Extension Manager) + * - ovsx (OpenVSX CLI) + */ + +import { execFileSync, execSync } from "node:child_process" +import fs from "node:fs" +import path from "node:path" +import { fileURLToPath } from "node:url" + +// Get __dirname equivalent in ES modules +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +// ANSI color codes for console output +const colors = { + reset: "\x1b[0m", + red: "\x1b[31m", + green: "\x1b[32m", + yellow: "\x1b[33m", +} + +// Logging utilities +const log = { + info: (msg) => console.log(`${colors.green}[INFO]${colors.reset} ${msg}`), + warn: (msg) => console.log(`${colors.yellow}[WARN]${colors.reset} ${msg}`), + error: (msg) => console.error(`${colors.red}[ERROR]${colors.reset} ${msg}`), +} + +// Configuration +const config = { + // The name and display name for the nightly version + nightlyName: "cline-nightly", + nightlyDisplayName: "Cline (Nightly)", + projectRoot: path.join(__dirname, ".."), + get packageJsonPath() { + return path.join(this.projectRoot, "package.json") + }, + get packageBackupPath() { + return path.join(this.projectRoot, "package.json.backup") + }, + get distDir() { + return path.join(this.projectRoot, "dist") + }, + get vsixPath() { + return path.join(this.distDir, "cline-nightly.vsix") + }, +} + +// Utility class for managing the publish process +class NightlyPublisher { + constructor() { + this.originalPackageJson = null + this.hasBackup = false + } + + /** + * Check if required dependencies are installed + */ + checkDependencies() { + const dependencies = [ + { name: "vsce", check: "vsce --version" }, + { name: "npx", check: "npx --version" }, + ] + + const missing = [] + + for (const dep of dependencies) { + try { + execSync(dep.check, { stdio: "ignore" }) + } catch { + missing.push(dep.name) + } + } + + if (missing.length > 0) { + throw new Error( + `Missing required dependencies: ${missing.join(", ")}. Please install them before running this script.`, + ) + } + + log.info("All dependencies are installed") + } + + /** + * Check if a command exists + */ + commandExists(command) { + try { + execSync(`which ${command}`, { stdio: "ignore" }) + return true + } catch { + return false + } + } + + /** + * Create backup of package.json + */ + backupPackageJson() { + if (!fs.existsSync(config.packageJsonPath)) { + throw new Error(`package.json not found at ${config.packageJsonPath}`) + } + + log.info("Backing up original package.json") + this.originalPackageJson = fs.readFileSync(config.packageJsonPath, "utf-8") + fs.writeFileSync(config.packageBackupPath, this.originalPackageJson) + this.hasBackup = true + } + + /** + * Restore original package.json + */ + restorePackageJson() { + if (this.hasBackup && fs.existsSync(config.packageBackupPath)) { + log.info("Restoring original package.json") + fs.writeFileSync(config.packageJsonPath, this.originalPackageJson) + fs.unlinkSync(config.packageBackupPath) + this.hasBackup = false + } + } + + /** + * Generate new version with timestamp + * Format: major.minor.timestamp + */ + generateVersion(currentVersion) { + // Extract major.minor from current version (e.g., "3.27.1" -> "3.27") + const versionParts = currentVersion.split(".") + if (versionParts.length < 2) { + throw new Error(`Invalid version format: ${currentVersion}`) + } + + const major = versionParts[0] + const minor = versionParts[1] + const timestamp = Math.floor(Date.now() / 1000) + + return `${major}.${minor}.${timestamp}` + } + + /** + * Update package.json with nightly configuration + */ + updatePackageJson() { + const pkg = JSON.parse(fs.readFileSync(config.packageJsonPath, "utf-8")) + const currentVersion = pkg.version + + if (!currentVersion) { + throw new Error("Could not read version from package.json") + } + + log.info(`Current version: ${currentVersion}`) + + const newVersion = this.generateVersion(currentVersion) + log.info(`New version: ${newVersion}`) + + // Update package.json fields + pkg.version = newVersion + pkg.name = config.nightlyName + pkg.displayName = config.nightlyDisplayName + + // Save updated package.json + log.info("Updating package.json for nightly build") + fs.writeFileSync(config.packageJsonPath, JSON.stringify(pkg, null, "\t")) + + return newVersion + } + + /** + * Package the extension + */ + packageExtension() { + // Ensure dist directory exists + if (!fs.existsSync(config.distDir)) { + fs.mkdirSync(config.distDir, { recursive: true }) + } + + log.info("Packaging extension") + + const args = [ + "package", + "--pre-release", + "--no-update-package-json", + "--no-git-tag-version", + "--no-dependencies", + "--out", + config.vsixPath, + ] + + try { + execFileSync("vsce", args, { + stdio: "inherit", + cwd: config.projectRoot, + }) + log.info(`Package created: ${config.vsixPath}`) + } catch (error) { + throw new Error(`Failed to package extension: ${error.message}`) + } + } + + /** + * Publish to VS Code Marketplace + */ + publishToVSCodeMarketplace() { + const token = process.env.VSCE_PAT + + if (!token) { + log.warn("VSCE_PAT not set, skipping VS Code Marketplace publish") + return false + } + + log.info("Publishing to VS Code Marketplace") + + const args = ["publish", "--pre-release", "--no-git-tag-version", "--packagePath", config.vsixPath] + + try { + execFileSync("vsce", args, { + env: { ...process.env, VSCE_PAT: token }, + stdio: "inherit", + cwd: config.projectRoot, + }) + log.info("Successfully published to VS Code Marketplace") + return true + } catch (error) { + throw new Error(`Failed to publish to VS Code Marketplace: ${error.message}`) + } + } + + /** + * Publish to OpenVSX Registry + */ + publishToOpenVSX() { + const token = process.env.OVSX_PAT + + if (!token) { + log.warn("OVSX_PAT not set, skipping OpenVSX Registry publish") + return false + } + + log.info("Publishing to OpenVSX Registry") + + const args = ["ovsx", "publish", "--pre-release", "--packagePath", config.vsixPath, "--pat", token] + + try { + execFileSync("npx", args, { + stdio: "inherit", + cwd: config.projectRoot, + }) + log.info("Successfully published to OpenVSX Registry") + return true + } catch (error) { + throw new Error(`Failed to publish to OpenVSX Registry: ${error.message}`) + } + } + + /** + * Main execution flow + */ + async run(isDryRun = false) { + try { + log.info(`Starting nightly publish process${isDryRun ? " (dry run)" : ""}`) + + // Step 1: Check dependencies + this.checkDependencies() + + // Step 2: Backup package.json + this.backupPackageJson() + + // Step 3: Update package.json + const newVersion = this.updatePackageJson() + + // Step 4: Package extension + this.packageExtension() + + // Step 5: Publish to marketplaces (skip if dry run) + let vsCodePublished = false + let openVSXPublished = false + + if (isDryRun) { + log.info("Dry run mode: Skipping marketplace publishing") + } else { + vsCodePublished = this.publishToVSCodeMarketplace() + openVSXPublished = this.publishToOpenVSX() + } + + // Summary + log.info(`Nightly publish process completed successfully${isDryRun ? " (dry run)" : ""}`) + log.info(`Package created for v${newVersion}: ${config.vsixPath}`) + + if (!isDryRun && !vsCodePublished && !openVSXPublished) { + log.warn("Extension was packaged but not published to any marketplace") + log.warn("Set VSCE_PAT and/or OVSX_PAT environment variables to enable publishing") + } + } catch (error) { + log.error(`Publish failed: ${error.message}`) + process.exit(1) + } finally { + // Always restore package.json + this.restorePackageJson() + } + } +} + +// Handle cleanup on process exit +const publisher = new NightlyPublisher() + +process.on("exit", () => { + publisher.restorePackageJson() +}) + +process.on("SIGINT", () => { + log.info("\nInterrupted, cleaning up...") + publisher.restorePackageJson() + process.exit(130) +}) + +process.on("SIGTERM", () => { + log.info("\nTerminated, cleaning up...") + publisher.restorePackageJson() + process.exit(143) +}) + +// Parse command line arguments +const args = process.argv.slice(2) +const isDryRun = args.includes("--dry-run") || args.includes("-n") +const showHelp = args.includes("--help") || args.includes("-h") + +if (showHelp) { + console.log(` +Nightly publish script for VS Code extension + +Usage: + npm run publish:marketplace:nightly [options] + +Options: + --dry-run, -n Run without actually publishing (package only) + --help, -h Show this help message + +Environment variables: + VSCE_PAT Personal Access Token for VS Code Marketplace + OVSX_PAT Personal Access Token for OpenVSX Registry + +Examples: + npm run publish:marketplace:nightly # Full publish + npm run publish:marketplace:nightly -- --dry-run # Package only + VSCE_PAT="token" npm run publish:marketplace:nightly # Publish to VS Code only +`) + process.exit(0) +} + +// Run the publisher +publisher.run(isDryRun).catch((error) => { + log.error(error.message) + process.exit(1) +})