Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions tools/releaser/src/commands/build.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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);
}
}
120 changes: 120 additions & 0 deletions tools/releaser/src/commands/get-app-runs.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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];
}
28 changes: 26 additions & 2 deletions tools/releaser/src/commands/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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:'));
Expand Down
55 changes: 55 additions & 0 deletions tools/releaser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -31,6 +33,7 @@ program.command('check')
program.command('start')
.description('Update package.json with new version and commit changes')
.argument('<release-version>', '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')
Expand All @@ -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 <git-ref>', '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>', 'Platform filter: all, windows, mac, or linux', 'all')
.option('--latest <number>', 'Number of recent runs to fetch when listing', '1')
.option('-o, --output <format>', '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 <git-ref> 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();
29 changes: 29 additions & 0 deletions tools/releaser/src/utils/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Loading