diff --git a/src/targets/__tests__/brew.test.ts b/src/targets/__tests__/brew.test.ts new file mode 100644 index 00000000..18fa51c0 --- /dev/null +++ b/src/targets/__tests__/brew.test.ts @@ -0,0 +1,151 @@ +import { vi } from 'vitest'; +import { NoneArtifactProvider } from '../../artifact_providers/none'; +import { ConfigurationError } from '../../utils/errors'; +import { BrewTarget } from '../brew'; + +// Mock the GitHub API client +vi.mock('../../utils/githubApi', () => ({ + getGitHubClient: vi.fn(() => ({ + repos: { + getContent: vi.fn(), + createOrUpdateFileContents: vi.fn(), + }, + })), +})); + +const DEFAULT_GITHUB_REPO = { + owner: 'getsentry', + repo: 'sentry-cli', +}; + +/** Returns a new BrewTarget test instance. */ +function getBrewTarget( + config: Record = {}, + githubRepo = DEFAULT_GITHUB_REPO +): BrewTarget { + return new BrewTarget( + { + name: 'brew', + template: 'class TestFormula < Formula\nend', + tap: 'getsentry/tools', + ...config, + }, + new NoneArtifactProvider(), + githubRepo + ); +} + +describe('BrewTarget configuration', () => { + test('throws on missing template', () => { + expect(() => { + new BrewTarget( + { name: 'brew' }, + new NoneArtifactProvider(), + DEFAULT_GITHUB_REPO + ); + }).toThrow(ConfigurationError); + }); + + test('parses tap correctly', () => { + const brewTarget = getBrewTarget(); + expect(brewTarget.brewConfig.tapRepo).toEqual({ + owner: 'getsentry', + repo: 'homebrew-tools', + }); + }); + + test('defaults to homebrew-core when no tap specified', () => { + const brewTarget = getBrewTarget({ tap: undefined }); + expect(brewTarget.brewConfig.tapRepo).toEqual({ + owner: 'homebrew', + repo: 'homebrew-core', + }); + }); + + test('throws on invalid tap name', () => { + expect(() => { + getBrewTarget({ tap: 'invalid' }); + }).toThrow(ConfigurationError); + }); +}); + +describe('formula name templating', () => { + test('formula name without template variables', () => { + const brewTarget = getBrewTarget({ formula: 'sentry-cli' }); + const resolved = brewTarget.resolveFormulaName('10.2.3'); + expect(resolved).toBe('sentry-cli'); + }); + + test('formula name with major version variable', () => { + const brewTarget = getBrewTarget({ formula: 'sentry-cli-v{{{major}}}' }); + const resolved = brewTarget.resolveFormulaName('10.2.3'); + expect(resolved).toBe('sentry-cli-v10'); + }); + + test('formula name with multiple version variables', () => { + const brewTarget = getBrewTarget({ + formula: 'sentry-cli-{{{major}}}-{{{minor}}}-{{{patch}}}', + }); + const resolved = brewTarget.resolveFormulaName('10.2.3'); + expect(resolved).toBe('sentry-cli-10-2-3'); + }); + + test('formula name with full version variable', () => { + const brewTarget = getBrewTarget({ formula: 'sentry-cli-{{{version}}}' }); + const resolved = brewTarget.resolveFormulaName('10.2.3'); + expect(resolved).toBe('sentry-cli-10.2.3'); + }); + + test('formula name with prerelease version still parses major', () => { + const brewTarget = getBrewTarget({ formula: 'sentry-cli-v{{{major}}}' }); + const resolved = brewTarget.resolveFormulaName('10.2.3-alpha.1'); + expect(resolved).toBe('sentry-cli-v10'); + }); + + test('falls back to repo name when no formula specified', () => { + const brewTarget = getBrewTarget({ formula: undefined }); + const resolved = brewTarget.resolveFormulaName('1.0.0'); + expect(resolved).toBe('sentry-cli'); + }); + + test('repo name with version template', () => { + const brewTarget = getBrewTarget( + { formula: undefined }, + { owner: 'getsentry', repo: 'craft-v{{{major}}}' } + ); + const resolved = brewTarget.resolveFormulaName('2.5.0'); + expect(resolved).toBe('craft-v2'); + }); +}); + +describe('prerelease skip', () => { + test('skips publishing for beta version', async () => { + const brewTarget = getBrewTarget(); + const result = await brewTarget.publish('2.0.0-beta.0', 'abc123'); + expect(result).toBeUndefined(); + }); + + test('skips publishing for alpha version', async () => { + const brewTarget = getBrewTarget(); + const result = await brewTarget.publish('1.5.0-alpha.1', 'abc123'); + expect(result).toBeUndefined(); + }); + + test('skips publishing for rc version', async () => { + const brewTarget = getBrewTarget(); + const result = await brewTarget.publish('3.0.0-rc.1', 'abc123'); + expect(result).toBeUndefined(); + }); + + test('skips publishing for preview version', async () => { + const brewTarget = getBrewTarget(); + const result = await brewTarget.publish('2.0.0-preview.1', 'abc123'); + expect(result).toBeUndefined(); + }); + + test('skips publishing for dev version', async () => { + const brewTarget = getBrewTarget(); + const result = await brewTarget.publish('1.0.0-dev.1', 'abc123'); + expect(result).toBeUndefined(); + }); +}); diff --git a/src/targets/brew.ts b/src/targets/brew.ts index 60d68973..ed9c27d2 100644 --- a/src/targets/brew.ts +++ b/src/targets/brew.ts @@ -7,6 +7,7 @@ import { getGitHubClient } from '../utils/githubApi'; import { isDryRun } from '../utils/helpers'; import { renderTemplateSafe } from '../utils/strings'; import { HashAlgorithm, HashOutputFormat } from '../utils/system'; +import { isPreviewRelease, parseVersion } from '../utils/version'; import { BaseTarget } from './base'; import { BaseArtifactProvider, @@ -111,6 +112,34 @@ export class BrewTarget extends BaseTarget { }; } + /** + * Resolves the formula name by interpolating version variables. + * + * Supports Mustache-style templates with the following variables: + * - `{{{version}}}`: Full version string (e.g., "10.2.3") + * - `{{{major}}}`: Major version number (e.g., "10") + * - `{{{minor}}}`: Minor version number (e.g., "2") + * - `{{{patch}}}`: Patch version number (e.g., "3") + * + * Example: `sentry-cli-v{{{major}}}` becomes `sentry-cli-v10` + * + * @param version The version string to interpolate + * @returns The resolved formula name with variables substituted + */ + public resolveFormulaName(version: string): string { + const formulaTemplate = this.brewConfig.formula || this.githubRepo.repo; + const parsedVersion = parseVersion(version); + + const context = { + version, + major: parsedVersion?.major ?? '', + minor: parsedVersion?.minor ?? '', + patch: parsedVersion?.patch ?? '', + }; + + return renderTemplateSafe(formulaTemplate, context); + } + /** * Resolves the content sha of a formula at the specified location. If the * formula does not exist, `undefined` is returned. @@ -147,11 +176,18 @@ export class BrewTarget extends BaseTarget { * @param revision The SHA revision of the new version */ public async publish(version: string, revision: string): Promise { - const { formula, path, template, tapRepo } = this.brewConfig; - const { owner, repo } = this.githubRepo; + // Skip pre-release versions as Homebrew doesn't understand them + if (isPreviewRelease(version)) { + this.logger.info( + 'Skipping Homebrew release for pre-release version: ' + version + ); + return undefined; + } + + const { path, template, tapRepo } = this.brewConfig; - // Get default formula name and location from the config - const formulaName = formula || repo; + // Get formula name (supports mustache templates with version variables) + const formulaName = this.resolveFormulaName(version); const formulaPath = path ? `${path}/${formulaName}.rb` : `Formula/${formulaName}.rb`; @@ -180,7 +216,7 @@ export class BrewTarget extends BaseTarget { this.logger.debug(`Homebrew formula for ${formulaName}:\n${data}`); // Try to find the repository to publish in - if (tapRepo.owner !== owner) { + if (tapRepo.owner !== this.githubRepo.owner) { // TODO: Create a PR if we have no push rights to this repo this.logger.warn('Skipping homebrew release: PRs not supported yet'); return undefined; @@ -196,7 +232,7 @@ export class BrewTarget extends BaseTarget { }; this.logger.info( - `Releasing ${owner}/${repo} tag ${version} ` + + `Releasing ${this.githubRepo.owner}/${this.githubRepo.repo} tag ${version} ` + `to homebrew tap ${tapRepo.owner}/${tapRepo.repo} ` + `formula ${formulaName}` );