Ctrl K
ComunidadGuías…

scripts/release-scope.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { execFileSync } from 'node:child_process'
import process from 'node:process'

export type Component = 'capgo' | 'cli' | 'notifications'
export type ReleaseAs = 'patch' | 'minor' | 'major'
type GitRunner = (args: string[]) => string

export interface PendingReleaseScope {
  base: string | null
  releaseAs: ReleaseAs
  shouldRelease: boolean
}

const capgoRootMatchers = [
  /^package\.json$/,
  /^bun\.lock$/,
  /^\.npmrc$/,
  /^\.typos\.toml$/,
  /^bunfig\.toml$/,
  /^tsconfig\.json$/,
  /^vitest\.config\.ts$/,
  /^vitest\.config\.cloudflare\.ts$/,
  /^vitest\.config\.cloudflare-plugin\.ts$/,
]

export const componentMatchers: Record<Component, RegExp[]> = {
  capgo: [
    ...capgoRootMatchers,
    /^\.github\/workflows\/build_and_deploy\.yml$/,
    /^\.github\/workflows\/build_mobile_ios\.yml$/,
    /^scripts\/deploy-scope\.ts$/,
    /^scripts\/(check-read-replica-hyperdrive-schema\.sh|sync-read-replica-schema\.ts)$/,
    /^aliproxy\//,
    /^android\//,
    /^assets\//,
    /^cloudflare_workers\//,
    /^configs\.json$/,
    /^deno-env\.d\.ts$/,
    /^deno\.lock$/,
    /^icons\//,
    /^index\.html$/,
    /^internal\//,
    /^ionic\.config\.json$/,
    /^ios\//,
    /^jean\.json$/,
    /^messages\//,
    /^public\//,
    /^read_replicate\//,
    /^scriptable\//,
    /^scripts\/ensure-native-notification-queues\.ts$/,
    /^scripts\/ensure-r2-inventory-queues\.ts$/,
    /^shared\//,
    /^sql\//,
    /^src\//,
    /^supabase\//,
    /^capacitor\.config\.ts$/,
    /^vite\.config\.mts$/,
    /^wrangler\.jsonc$/,
  ],
  cli: [
    /^cli\/src\//,
    /^cli\/skills\/[^/]+\/SKILL\.md$/,
    /^cli\/skills\/(?!.*\.(md|mdx)$)/,
    /^cli\/package\.json$/,
    /^cli\/build\.mjs$/,
    /^cli\/tsconfig\.json$/,
    /^cli\/\.npmrc$/,
  ],
  notifications: [
    /^packages\/capacitor-notifications\//,
  ],
}

function runGit(args: string[]): string {
  return execFileSync('git', args, {
    encoding: 'utf8',
    stdio: ['ignore', 'pipe', 'pipe'],
  }).trim()
}

function isDescribeWithoutMatchingTag(error: unknown): boolean {
  if (!(error instanceof Error))
    return false

  const stderr = typeof (error as NodeJS.ErrnoException & { stderr?: string }).stderr === 'string'
    ? (error as NodeJS.ErrnoException & { stderr: string }).stderr
    : ''
  const message = `${error.message}\n${stderr}`
  return /no names found|no tags can describe|no tags exactly match|not enough tags|not a valid object name/i.test(message)
}

function getCommitShas(before: string, after: string, run: GitRunner = runGit): string[] {
  const isZero = before === '' || /^0+$/.test(before)

  if (!isZero) {
    const commits = run(['rev-list', '--reverse', `${before}..${after}`])
    return commits ? commits.split('\n').filter(Boolean) : []
  }

  const parents = run(['rev-list', '--parents', '-n', '1', after]).split(' ')
  if (parents.length > 1) {
    const commits = run(['rev-list', '--reverse', `${after}^..${after}`])
    return commits ? commits.split('\n').filter(Boolean) : []
  }

  return [after]
}

function getChangedFiles(commit: string, run: GitRunner = runGit): string[] {
  const files = run(['show', '--format=', '--name-only', commit])
  return files ? files.split('\n').filter(Boolean) : []
}

function getCommitMessage(commit: string, run: GitRunner = runGit): { subject: string, body: string } {
  return {
    subject: run(['log', '-1', '--format=%s', commit]),
    body: run(['log', '-1', '--format=%b', commit]),
  }
}

export function matchesComponent(component: Component, files: string[]): boolean {
  return files.some(file => componentMatchers[component].some(pattern => pattern.test(file)))
}

export function getSeverity(subject: string, body: string): number {
  const conventionalMatch = subject.match(/^([a-z]+)(\([^)]+\))?(!)?:/)
  const hasBreakingChange = body.includes('BREAKING CHANGE:') || conventionalMatch?.[3] === '!'

  if (hasBreakingChange) {
    return 3
  }

  if (conventionalMatch?.[1] === 'feat') {
    return 2
  }

  return 1
}

export function toReleaseAs(severity: number): ReleaseAs {
  if (severity >= 3) {
    return 'major'
  }

  if (severity === 2) {
    return 'minor'
  }

  return 'patch'
}

function resolveCommitsScope(component: Component, commits: string[], run: GitRunner) {
  let shouldRelease = false
  let highestSeverity = 0

  for (const commit of commits) {
    const files = getChangedFiles(commit, run)
    if (!matchesComponent(component, files)) {
      continue
    }

    const message = getCommitMessage(commit, run)
    if (message.subject.startsWith('chore(auto-sync):'))
      continue

    shouldRelease = true
    highestSeverity = Math.max(highestSeverity, getSeverity(message.subject, message.body))
  }

  return {
    shouldRelease,
    releaseAs: shouldRelease ? capReleaseAs(component, toReleaseAs(highestSeverity)) : 'patch' as ReleaseAs,
  }
}

/**
 * Capacitor plugins keep their major version equal to the Capacitor major they
 * target (8.x.y for Capacitor 8), so a breaking change only bumps the minor.
 * The major moves by hand together with the Capacitor upgrade.
 */
export function capReleaseAs(component: Component, releaseAs: ReleaseAs): ReleaseAs {
  if (component === 'notifications' && releaseAs === 'major')
    return 'minor'
  return releaseAs
}

export function resolveReleaseScope(component: Component, before: string, after: string, run: GitRunner = runGit) {
  const commits = getCommitShas(before, after, run)

  return resolveCommitsScope(component, commits, run)
}

export function resolvePendingReleaseScope(
  component: Component,
  after: string,
  includePrereleaseTags: boolean,
  run: GitRunner = runGit,
): PendingReleaseScope {
  const describeArgs = includePrereleaseTags
    ? ['describe', '--tags', '--match', `${component}-*-alpha.*`, '--abbrev=0', after]
    : [
        'describe',
        '--tags',
        '--match',
        `${component}-[0-9]*`,
        '--exclude',
        `${component}-*-alpha.*`,
        '--abbrev=0',
        after,
      ]

  let base: string | null = null
  try {
    base = run(describeArgs) || null
  }
  catch (error) {
    if (!isDescribeWithoutMatchingTag(error))
      throw error
    base = null
  }

  const range = base ? `${base}..${after}` : after
  const commitsOutput = run(['rev-list', '--reverse', range])
  const commits = commitsOutput ? commitsOutput.split('\n').filter(Boolean) : []
  const scope = resolveCommitsScope(component, commits, run)

  return {
    base,
    ...scope,
  }
}

if (import.meta.main) {
  const componentArg = process.argv[2]
  const modeOrBefore = process.argv[3] ?? ''
  const after = process.argv[4] ?? 'HEAD'

  if (componentArg !== 'capgo' && componentArg !== 'cli' && componentArg !== 'notifications') {
    console.error('Usage: bun scripts/release-scope.ts <capgo|cli|notifications> [--latest-stable|--latest-alpha|before] [after]')
    process.exit(1)
  }

  const isLatestMode = modeOrBefore === '--latest-stable' || modeOrBefore === '--latest-alpha'
  const scope = isLatestMode
    ? resolvePendingReleaseScope(componentArg, after, modeOrBefore === '--latest-alpha')
    : resolveReleaseScope(componentArg, modeOrBefore, after)

  if ('base' in scope)
    console.log(`base=${scope.base ?? ''}`)
  console.log(`should_release=${scope.shouldRelease}`)
  console.log(`release_as=${scope.releaseAs}`)
}