diff --git a/tools/releaser/src/commands/build.ts b/tools/releaser/src/commands/build.ts new file mode 100644 index 00000000000..bee34e94384 --- /dev/null +++ b/tools/releaser/src/commands/build.ts @@ -0,0 +1,61 @@ +import chalk from 'chalk'; +import inquirer from 'inquirer'; +import { triggerBuildWorkflows } from '../utils/github.js'; + +interface BuildOptions { + platform?: string; + force?: boolean; +} + +const VALID_PLATFORMS = ['all', 'windows', 'mac', 'linux']; + +export async function buildArtifacts(gitRef: string, options: BuildOptions): Promise { + const platform = options.platform || 'all'; + + // Validate platform + if (!VALID_PLATFORMS.includes(platform)) { + console.error(chalk.red(`Error: Invalid platform "${platform}". Valid options are: ${VALID_PLATFORMS.join(', ')}`)); + process.exit(1); + } + + console.log(chalk.blue(`Triggering build artifacts for platform(s): ${platform}`)); + console.log(chalk.blue(`Using git ref: ${gitRef}`)); + + try { + // Confirm unless --force is used + if (!options.force) { + const { confirmed } = await inquirer.prompt([ + { + type: 'confirm', + name: 'confirmed', + message: chalk.yellow(`Are you sure you want to trigger build workflows for ${platform} using ref "${gitRef}"?`), + default: false + } + ]); + + if (!confirmed) { + console.log(chalk.yellow('Build trigger cancelled')); + return; + } + } + + // Trigger the workflows + const runs = await triggerBuildWorkflows(gitRef, platform); + + console.log(chalk.green(`\n✅ Successfully triggered build workflow(s) for ${platform}`)); + + if (runs.length > 0) { + console.log(chalk.blue('\nTriggered workflow runs:')); + runs.forEach(run => { + console.log(chalk.cyan(` • ${run.name}: ${run.url}`)); + }); + } + + console.log(chalk.blue('\nYou can monitor all workflows at:')); + console.log(chalk.cyan('https://github.com/kubernetes-sigs/headlamp/actions')); + } catch (error) { + console.error(chalk.red('Error triggering build workflows:')); + console.error(error); + process.exit(1); + } +} diff --git a/tools/releaser/src/commands/get-app-runs.ts b/tools/releaser/src/commands/get-app-runs.ts new file mode 100644 index 00000000000..c23e00c2383 --- /dev/null +++ b/tools/releaser/src/commands/get-app-runs.ts @@ -0,0 +1,120 @@ +import chalk from 'chalk'; +import { getLatestAppRuns } from '../utils/github.js'; + +interface GetAppRunsOptions { + latest?: number; + platform?: string; + output?: string; +} + +const VALID_PLATFORMS = ['all', 'windows', 'mac', 'linux']; +const VALID_OUTPUT_FORMATS = ['simple', 'json']; + +export async function getAppRuns(options: GetAppRunsOptions): Promise { + const limit = options.latest || 1; + const platform = options.platform || 'all'; + const output = options.output; + + // Validate platform + if (!VALID_PLATFORMS.includes(platform)) { + console.error(chalk.red(`Error: Invalid platform "${platform}". Valid options are: ${VALID_PLATFORMS.join(', ')}`)); + process.exit(1); + } + + // Validate output format + if (output && !VALID_OUTPUT_FORMATS.includes(output)) { + console.error(chalk.red(`Error: Invalid output format "${output}". Valid options are: ${VALID_OUTPUT_FORMATS.join(', ')}`)); + process.exit(1); + } + + if (output !== 'json') { + const platformDesc = platform === 'all' ? 'each platform' : platform; + console.log(chalk.blue(`Fetching latest ${limit} app build run${limit > 1 ? 's' : ''} for ${platformDesc}...\n`)); + } + + try { + const runs = await getLatestAppRuns(limit, platform); + + if (runs.length === 0) { + if (output === 'json') { + console.log(JSON.stringify([], null, 2)); + } else { + console.log(chalk.yellow('No workflow runs found')); + } + return; + } + + // JSON output + if (output === 'json') { + console.log(JSON.stringify(runs, null, 2)); + return; + } + + // Simple output - just platform name and run URL + if (output === 'simple') { + runs.forEach((workflowRuns) => { + workflowRuns.runs.forEach((run) => { + console.log(`${workflowRuns.workflowName}: ${run.url}`); + }); + }); + return; + } + + // Default detailed output + runs.forEach((workflowRuns, index) => { + if (index > 0) { + console.log(''); // Add spacing between workflows + } + + console.log(chalk.bold.cyan(`${workflowRuns.workflowName}:`)); + console.log(chalk.dim('─'.repeat(60))); + + workflowRuns.runs.forEach((run, runIndex) => { + const statusIcon = run.status === 'completed' + ? (run.conclusion === 'success' ? '✅' : run.conclusion === 'failure' ? '❌' : '⚠️') + : '🔄'; + + const statusColor = run.status === 'completed' + ? (run.conclusion === 'success' ? chalk.green : run.conclusion === 'failure' ? chalk.red : chalk.yellow) + : chalk.blue; + + console.log(`\n${runIndex + 1}. ${statusIcon} ${statusColor(run.status.toUpperCase())}${run.conclusion ? ` (${run.conclusion})` : ''}`); + console.log(chalk.dim(` Run ID: ${run.id}`)); + console.log(chalk.dim(` Branch: ${run.headBranch}`)); + console.log(chalk.dim(` Commit: ${run.headSha.substring(0, 7)}`)); + console.log(chalk.dim(` Created: ${new Date(run.createdAt).toLocaleString()}`)); + console.log(chalk.cyan(` URL: ${run.url}`)); + + if (run.artifacts.length > 0) { + console.log(chalk.green(` Artifacts (${run.artifacts.length}):`)); + run.artifacts.forEach(artifact => { + console.log(chalk.dim(` • ${artifact.name} (${formatBytes(artifact.size)})`)); + console.log(chalk.dim(` Download: ${artifact.downloadUrl}`)); + }); + } else if (run.status === 'completed' && run.conclusion === 'success') { + console.log(chalk.yellow(` No artifacts available`)); + } + }); + }); + + console.log('\n' + chalk.dim('─'.repeat(60))); + console.log(chalk.blue('\nView all runs at:')); + console.log(chalk.cyan('https://github.com/kubernetes-sigs/headlamp/actions')); + } catch (error) { + if (output === 'json') { + console.error(JSON.stringify({ error: String(error) }, null, 2)); + } else { + console.error(chalk.red('Error fetching app runs:')); + console.error(error); + } + process.exit(1); + } +} + +function formatBytes(bytes: number): string { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]; +} diff --git a/tools/releaser/src/commands/start.ts b/tools/releaser/src/commands/start.ts index 0fd9e5973a4..b0109b9c2fe 100644 --- a/tools/releaser/src/commands/start.ts +++ b/tools/releaser/src/commands/start.ts @@ -2,17 +2,38 @@ import chalk from 'chalk'; import path from 'path'; import fs from 'fs'; import { execSync } from 'child_process'; -import { getRepoRoot, commitVersionChange } from '../utils/git.js'; +import { getRepoRoot, commitVersionChange, branchExists, createAndCheckoutBranch, getCurrentBranch } from '../utils/git.js'; import { sanitizeVersion } from '../utils/version.js'; -export function startRelease(releaseVersion: string): void { +interface StartOptions { + noBranch?: boolean; +} + +export function startRelease(releaseVersion: string, options: StartOptions): void { const version = sanitizeVersion(releaseVersion); + const branchName = `hl-rc-${version}`; + let branchCreated = false; + console.log(chalk.blue(`Starting release process for version ${version}...`)); const repoRoot = getRepoRoot(); const packageJsonPath = path.join(repoRoot, 'app', 'package.json'); try { + // Create branch unless --no-branch is specified + if (!options.noBranch) { + if (branchExists(branchName)) { + console.log(chalk.yellow(`⚠️ Branch ${branchName} already exists, staying on current branch`)); + const currentBranch = getCurrentBranch(); + console.log(chalk.blue(`Current branch: ${currentBranch}`)); + } else { + console.log(chalk.blue(`Creating and checking out branch ${branchName}...`)); + createAndCheckoutBranch(branchName); + branchCreated = true; + console.log(chalk.green(`✅ Created and checked out branch ${branchName}`)); + } + } + // Update package.json with new version const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); packageJson.version = version; @@ -31,6 +52,9 @@ export function startRelease(releaseVersion: string): void { console.log(chalk.green(`✅ Changes committed with message "app: Bump version to ${version}"`)); console.log(chalk.green(`\nRelease ${version} has been started successfully!`)); + if (branchCreated) { + console.log(chalk.blue(`Branch: ${branchName}`)); + } console.log(`You can now create a tag with 'releaser tag' and publish with 'releaser publish ${version}'`); } catch (error) { console.error(chalk.red('Error starting release:')); diff --git a/tools/releaser/src/index.ts b/tools/releaser/src/index.ts index 308b17bc8e1..90ea2406376 100644 --- a/tools/releaser/src/index.ts +++ b/tools/releaser/src/index.ts @@ -8,6 +8,8 @@ import { checkRelease } from './commands/check.js'; import { startRelease } from './commands/start.js'; import { tagRelease } from './commands/tag.js'; import { publishRelease } from './commands/publish.js'; +import { buildArtifacts } from './commands/build.js'; +import { getAppRuns } from './commands/get-app-runs.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -31,6 +33,7 @@ program.command('check') program.command('start') .description('Update package.json with new version and commit changes') .argument('', 'New version number (e.g., 0.30.0)') + .option('--no-branch', 'Do not create a release branch (stay on current branch)') .action(startRelease); program.command('tag') @@ -43,4 +46,56 @@ program.command('publish') .option('--force', 'Skip confirmation prompt') .action(publishRelease); +// CI command with subcommands +const ci = program.command('ci') + .description('CI-related commands'); + +ci.command('app') + .description('Manage app build workflows') + .option('--build ', 'Trigger build artifact workflows for the specified git ref/branch/tag (e.g., main, v0.30.0)') + .option('--list', 'List the latest app build workflow runs') + .option('-p, --platform ', 'Platform filter: all, windows, mac, or linux', 'all') + .option('--latest ', 'Number of recent runs to fetch when listing', '1') + .option('-o, --output ', 'Output format when listing: simple or json') + .option('--force', 'Skip confirmation prompt when building') + .action(async (options) => { + // Check if both --build and --list are provided + if (options.build && options.list) { + console.error('Error: Cannot use both --build and --list options together'); + process.exit(1); + } + + // Check if neither --build nor --list are provided + if (!options.build && !options.list) { + console.error('Error: Must specify either --build or --list'); + process.exit(1); + } + + if (options.build) { + // Build artifacts + await buildArtifacts(options.build, { + platform: options.platform, + force: options.force, + }); + } else if (options.list) { + // List app runs + const latestNum = parseInt(options.latest, 10); + if ( + isNaN(latestNum) || + !Number.isInteger(latestNum) || + latestNum <= 0 + ) { + console.error( + `Error: --latest must be a valid positive integer (got "${options.latest}")` + ); + process.exit(1); + } + await getAppRuns({ + platform: options.platform, + latest: latestNum, + output: options.output, + }); + } + }); + program.parse(); diff --git a/tools/releaser/src/utils/git.ts b/tools/releaser/src/utils/git.ts index 56c050c0d66..dedb53d4678 100644 --- a/tools/releaser/src/utils/git.ts +++ b/tools/releaser/src/utils/git.ts @@ -59,3 +59,32 @@ export function pushTag(version: string): void { process.exit(1); } } + +export function branchExists(branchName: string): boolean { + try { + execSync(`git rev-parse --verify ${branchName}`, { stdio: 'ignore' }); + return true; + } catch (error) { + return false; + } +} + +export function createAndCheckoutBranch(branchName: string): void { + try { + execSync(`git checkout -b ${branchName}`); + } catch (error) { + console.error(`Error: Failed to create and checkout branch ${branchName}`); + console.error(error); + process.exit(1); + } +} + +export function getCurrentBranch(): string { + try { + return execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf-8' }).trim(); + } catch (error) { + console.error('Error: Failed to get current branch'); + console.error(error); + process.exit(1); + } +} diff --git a/tools/releaser/src/utils/github.ts b/tools/releaser/src/utils/github.ts index 4f548afc5e3..4b6ae1ea8e4 100644 --- a/tools/releaser/src/utils/github.ts +++ b/tools/releaser/src/utils/github.ts @@ -811,4 +811,233 @@ function displayExtendedAssetsResults(results: ExtendedAssetsStatus, version: st console.log(chalk.yellow(`⚠️ ${result.displayName} not found for v${version}`)); } }); -} \ No newline at end of file +} + +/** + * Trigger build artifact workflows for specified platform(s) + * + * @param gitRef The git ref/branch/tag to build from + * @param platform The platform to build: 'all', 'windows', 'mac', or 'linux' + * @returns Array of workflow run information + */ +export async function triggerBuildWorkflows( + gitRef: string, + platform: string +): Promise> { + const octokit = getOctokit(); + + interface WorkflowConfig { + name: string; + workflowId: string; + inputs: Record; + } + + const workflows: WorkflowConfig[] = []; + + if (platform === 'all' || platform === 'windows') { + workflows.push({ + name: 'Windows', + workflowId: 'app-artifacts-win.yml', + inputs: { + buildBranch: gitRef, + signBinaries: 'false' + } + }); + } + + if (platform === 'all' || platform === 'mac') { + workflows.push({ + name: 'Mac', + workflowId: 'app-artifacts-mac.yml', + inputs: { + buildBranch: gitRef, + signBinaries: 'false' + } + }); + } + + if (platform === 'all' || platform === 'linux') { + workflows.push({ + name: 'Linux', + workflowId: 'app-artifacts-linux.yml', + inputs: { + buildBranch: gitRef + } + }); + } + + console.log(chalk.blue(`\nTriggering ${workflows.length} workflow(s)...`)); + + const triggeredRuns: Array<{ name: string; runId: number; url: string }> = []; + + for (const workflow of workflows) { + try { + console.log(chalk.blue(` Triggering ${workflow.name} build workflow...`)); + + // Trigger the workflow + await octokit.actions.createWorkflowDispatch({ + owner: OWNER, + repo: REPO, + workflow_id: workflow.workflowId, + ref: gitRef, + inputs: workflow.inputs + }); + + // Poll for the workflow run to be created with retry mechanism + let latestRun = null; + const maxRetries = 10; + const retryDelay = 1000; // Start with 1 second + + for (let attempt = 0; attempt < maxRetries; attempt++) { + // Wait before checking (with exponential backoff) + const delay = retryDelay * Math.pow(1.5, attempt); + await new Promise(resolve => setTimeout(resolve, delay)); + + // Get the latest workflow run for this workflow + const { data: runs } = await octokit.actions.listWorkflowRuns({ + owner: OWNER, + repo: REPO, + workflow_id: workflow.workflowId, + per_page: 5 + }); + + // Find the most recent run (should be the one we just triggered) + if (runs.workflow_runs.length > 0) { + latestRun = runs.workflow_runs[0]; + break; + } + } + + if (latestRun) { + triggeredRuns.push({ + name: workflow.name, + runId: latestRun.id, + url: latestRun.html_url + }); + console.log(chalk.green(` ✅ ${workflow.name} workflow triggered`)); + console.log(chalk.dim(` Run ID: ${latestRun.id}`)); + } else { + console.log(chalk.green(` ✅ ${workflow.name} workflow triggered`)); + console.log(chalk.yellow(` (Run ID not immediately available)`)); + } + } catch (error: any) { + console.error(chalk.red(` ❌ Failed to trigger ${workflow.name} workflow:`)); + console.error(chalk.red(` ${error?.message || error}`)); + throw error; + } + } + + return triggeredRuns; +} + +/** + * Get latest app build workflow runs + * + * @param limit Number of runs to fetch per workflow + * @param platform Filter by platform: 'all', 'windows', 'mac', or 'linux' + * @returns Array of workflow runs with their details and artifacts + */ +export async function getLatestAppRuns( + limit: number = 5, + platform: string = 'all' +): Promise< + Array<{ + workflowName: string; + runs: Array<{ + id: number; + status: string; + conclusion: string | null; + headBranch: string; + headSha: string; + createdAt: string; + url: string; + artifacts: Array<{ + name: string; + size: number; + downloadUrl: string; + }>; + }>; + }> +> { + const octokit = getOctokit(); + + interface WorkflowConfig { + name: string; + workflowId: string; + } + + const workflows: WorkflowConfig[] = []; + + if (platform === 'all' || platform === 'windows') { + workflows.push({ + name: 'Windows', + workflowId: 'app-artifacts-win.yml' + }); + } + + if (platform === 'all' || platform === 'mac') { + workflows.push({ + name: 'Mac', + workflowId: 'app-artifacts-mac.yml' + }); + } + + if (platform === 'all' || platform === 'linux') { + workflows.push({ + name: 'Linux', + workflowId: 'app-artifacts-linux.yml' + }); + } + + const results = []; + + for (const workflow of workflows) { + try { + // Get workflow runs + const { data: runsData } = await octokit.actions.listWorkflowRuns({ + owner: OWNER, + repo: REPO, + workflow_id: workflow.workflowId, + per_page: limit + }); + + const runs = []; + + for (const run of runsData.workflow_runs) { + // Get artifacts for this run + const { data: artifactsData } = await octokit.actions.listWorkflowRunArtifacts({ + owner: OWNER, + repo: REPO, + run_id: run.id + }); + + const artifacts = artifactsData.artifacts.map(artifact => ({ + name: artifact.name, + size: artifact.size_in_bytes, + downloadUrl: `https://github.com/${OWNER}/${REPO}/actions/runs/${run.id}/artifacts/${artifact.id}` + })); + + runs.push({ + id: run.id, + status: run.status || 'unknown', + conclusion: run.conclusion, + headBranch: run.head_branch || 'unknown', + headSha: run.head_sha, + createdAt: run.created_at, + url: run.html_url, + artifacts + }); + } + + results.push({ + workflowName: workflow.name, + runs + }); + } catch (error: any) { + console.error(chalk.red(`Failed to fetch runs for ${workflow.name}:`)); + console.error(chalk.red(` ${error?.message || error}`)); + } + } + + return results; +}