All files / scripts/package-release/commands github.js

0% Statements 0/49
0% Branches 0/40
0% Functions 0/8
0% Lines 0/43

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127                                                                                                                                                                                                                                                             
import chalk from 'chalk';
import { enforceBranchGuardrail } from '../lib/branch.js';
import { revParse, getLastTagForPackage } from '../lib/git.js';
import { createGithubRelease } from '../lib/github.js';
import { generateChangelogNotes } from '../lib/changelog.js';
import { resolvePackage, discoverPackages } from '../lib/workspace.js';
import { getConfig } from '../lib/config.js';
import { heading, info, success, error, setDryRun } from '../lib/log.js';
 
export function registerGithubCommand(program) {
  program
    .command('github')
    .description('Create or update GitHub releases for package versions')
    .requiredOption('--packages <list>', 'Comma-separated list of name@version entries')
    .option('--sha <ref>', 'Target SHA (default: HEAD)', 'HEAD')
    .option('--from <ref>', 'Commit range start for release notes (default: previous tag)')
    .option('--force', 'Update existing release if present')
    .option('--skip-existing', 'Silently skip releases that already exist (idempotent for CI re-runs)')
    .option('--draft', 'Create release as draft')
    .option('--prerelease', 'Mark the release as a prerelease (default: auto-detected from version)')
    .option('--no-prerelease', 'Force prerelease=false even if the version looks like a prerelease')
    .option('--allow-branch <pattern>', 'Additive branch pattern override')
    .option('--no-branch-check', 'Bypass branch guardrail entirely')
    .option('--dry-run', 'Print planned actions without executing')
    .action(async (opts) => {
      if (opts.dryRun) setDryRun(true);
      try {
        await runGithub(opts);
      } catch (e) {
        error(e.message);
        process.exit(1);
      }
    });
}
 
function parsePackageVersionEntries(packagesArg) {
  return packagesArg
    .split(',')
    .map((s) => s.trim())
    .filter(Boolean)
    .map((entry) => {
      const atIdx = entry.lastIndexOf('@');
      if (atIdx <= 0) {
        error(`Invalid --packages entry "${entry}". Expected format: @scope/name@version`);
        process.exit(1);
      }
      return {
        name: entry.substring(0, atIdx),
        version: entry.substring(atIdx + 1),
      };
    });
}
 
async function runGithub(opts) {
  if (opts.branchCheck !== false) {
    enforceBranchGuardrail('github', {
      noBranchCheck: false,
      allowBranch: opts.allowBranch,
    });
  }
 
  if (opts.force && opts.skipExisting) {
    error('--force and --skip-existing are mutually exclusive.');
    process.exit(1);
  }
 
  const config = getConfig();
  const entries = parsePackageVersionEntries(opts.packages);
 
  heading('GitHub Release Plan');
 
  for (const entry of entries) {
    const tagName = config.tagFormat.replace('{name}', entry.name).replace('{version}', entry.version);
 
    const pkg = resolvePackage(entry.name);
    const pkgPaths = pkg ? [pkg.relativePath] : [];
 
    const fromRef = opts.from || findPreviousTag(entry.name, tagName);
    const notes = generateChangelogNotes(entry.name, entry.version, fromRef, pkgPaths);
 
    // Prerelease: if explicitly set (--prerelease or --no-prerelease), honor it.
    // Otherwise auto-detect from the version string (any SemVer prerelease
    // identifier like -alpha.N / -beta.N / -rc.N / -next / -canary / -dev).
    let prerelease;
    if (opts.prerelease === true) prerelease = true;
    else if (opts.prerelease === false) prerelease = false;
    else prerelease = isPrereleaseVersion(entry.version);
 
    info(`  ${chalk.bold(tagName)}`);
    info(`    Notes from: ${chalk.dim(fromRef || 'beginning')} → ${chalk.dim(opts.sha)}`);
    if (opts.draft) info(`    ${chalk.yellow('(draft)')}`);
    if (prerelease) info(`    ${chalk.yellow('(prerelease)')}`);
    info('');
 
    const result = createGithubRelease(tagName, {
      body: notes,
      sha: opts.sha === 'HEAD' ? undefined : opts.sha,
      draft: opts.draft || false,
      prerelease,
      force: opts.force || false,
      skipExisting: opts.skipExisting || false,
    });
    if (result.skipped) continue;
    success(`GitHub release: ${tagName}${result.updated ? ' (updated)' : ''}`);
  }
}
 
/**
 * Detect SemVer prerelease identifiers in a version string. Returns true for
 * `*-alpha.N`, `*-beta.N`, `*-rc.N`, `*-next`, `*-canary`, `*-dev`, `*-pre`,
 * and their plain (non-numbered) variants. Returns false for stable
 * versions like `1.2.3` or `2.30.7`.
 *
 * Exported for test coverage.
 */
export function isPrereleaseVersion(version) {
  return /-(alpha|beta|rc|next|canary|dev|pre)(\.\w+)?(\+|$)/i.test(version);
}
 
function findPreviousTag(packageName, currentTag) {
  const lastTag = getLastTagForPackage(packageName);
  if (lastTag && lastTag !== currentTag) {
    return lastTag;
  }
  return null;
}