diff --git a/docs/src/content/docs/targets/registry.md b/docs/src/content/docs/targets/registry.md index e30ea150..c836b824 100644 --- a/docs/src/content/docs/targets/registry.md +++ b/docs/src/content/docs/targets/registry.md @@ -16,19 +16,19 @@ Avoid having multiple `registry` targets—it supports batching multiple apps an | `apps` | Dict of app configs keyed by canonical name (e.g., `app:craft`) | | `sdks` | Dict of SDK configs keyed by canonical name (e.g., `maven:io.sentry:sentry`) | -### App/SDK Options +### Per-package options -| Option | Description | -|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Option | Description | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `urlTemplate` | URL template for artifact download links in the manifest. Supports `{{version}}`, `{{file}}`, and `{{revision}}` variables. Primarily for apps and CDN-hosted assets—not needed for SDK packages installed from public registries (npm, PyPI, etc.) | -| `linkPrereleases` | Update for preview releases. Default: `false` | -| `checksums` | List of checksum configs | -| `onlyIfPresent` | Only run if matching file exists | -| `name` | Human-readable name (used when creating new packages) | -| `sdkName` | SDK identifier matching the SDK's `sdk_info.name` field in Sentry events (e.g., `sentry.javascript.react`). Will create the `sdks/` symlink. (used when creating new packages) | -| `packageUrl` | Link to package registry page, e.g., npmjs.com (used when creating new packages) | -| `mainDocsUrl` | Link to main documentation (used when creating new packages) | -| `apiDocsUrl` | Link to API documentation (used when creating new packages) | +| `linkPrereleases` | By default, the registry target is skipped when the version is a pre-release (e.g. `1.0.0-beta.1`). Set to `true` to publish registry entries for pre-releases as well. | +| `checksums` | List of checksum configs (see [Checksum Configuration](#checksum-configuration)) | +| `onlyIfPresent` | Only run if artifact matches the given filename pattern | +| `name` | Human-readable name for the platform or package (e.g. `"Sentry Browser SDK"` or `"Sentry Craft"`) - (used when creating new packages) | +| `sdkName` | SDK identifier matching the SDK's `sdk_info.name` field in Sentry events (e.g., `sentry.javascript.react`). Will create the `sdks/` symlink. (used when creating new packages) | +| `packageUrl` | Link to the package registry page (e.g. npmjs.com, PyPI, crates.io). Not required for `app` types (used when creating new packages) | +| `mainDocsUrl` | Link to the main documentation page. If omitted, Craft falls back to `repo_url` and emits a warning. (used when creating new packages) | +| `apiDocsUrl` | Link to the API documentation (e.g. pkg.go.dev, javadoc.io) - (used when creating new packages) | ### Checksum Configuration @@ -38,31 +38,51 @@ checksums: format: hex # or base64 ``` -## Example +## How a registry entry is built + +Every time Craft publishes a release, it writes a version file (e.g. `packages/npm/@sentry/browser/1.2.3.json`) to the registry. The fields in that file come from different sources: + +| Registry field | `.craft.yml` option | Source | +|-----------------|---------------------|-----------------------------------------------------------------------------------------------------| +| `canonical` | _(dict key)_ | The dict key in `.craft.yml` (e.g. `npm:@sentry/browser`). Set once on first publish (not updated). | +| `version` | _(automatic)_ | The release version being published | +| `created_at` | _(automatic)_ | The current timestamp at publish time | +| `repo_url` | _(automatic)_ | Auto-detected from the `origin` git remote. Overwritten on every publish | +| `name` | `name` | Your `.craft.yml` config. Applied on every publish, can be updated at any time | +| `package_url` | `packageUrl` | Your `.craft.yml` config. Applied on every publish, can be updated at any time | +| `main_docs_url` | `mainDocsUrl` | Your `.craft.yml` config. Applied on every publish, can be updated at any time | +| `api_docs_url` | `apiDocsUrl` | Your `.craft.yml` config. Applied on every publish, can be updated at any time | + +The `canonical` field is the only one that cannot be changed after the first publish—it is written once and then validated for consistency on every subsequent run. To rename a canonical, you must manually update both the registry and your `.craft.yml` at the same time. + +`repo_url` is always resolved automatically and cannot be configured per-package. By default, Craft reads the `origin` git remote (both HTTPS and SSH formats are supported). If auto-detection is not possible, configure it via a top-level `github` block: ```yaml -targets: - - name: registry - sdks: - 'npm:@sentry/browser': - apps: - 'app:craft': - urlTemplate: 'https://downloads.sentry-cdn.com/craft/{{version}}/{{file}}' - checksums: - - algorithm: sha256 - format: hex +github: + owner: getsentry + repo: sentry-javascript ``` -## Package Types +## Adding a new package -- **sdk**: Package uploaded to public registries (PyPI, NPM, etc.) -- **app**: Standalone application with version files in the registry +When a package does not yet exist in the registry, Craft creates the directory structure and initial manifest automatically on the first publish. No manual registry changes are needed. -## Creating New Packages +For this to succeed, certain fields must be present in your `.craft.yml` before you publish for the first time. + +:::caution[Required metadata on first publish] + +- **`name`** — required for all package types. +- **`mainDocsUrl`** — required for all package types. If omitted, Craft falls back to `repo_url` and emits a warning, but you should always set it explicitly. + +- **`sdkName`** — required for SDK packages. +- **`packageUrl`** — required for SDK packages +::: -When you introduce a new package that doesn't yet exist in the release registry, Craft will automatically create the required directory structure and initial manifest on the first publish. +After the first publish, you can add or update any of these fields in `.craft.yml` and they will be applied to the manifest on the next release. -Supply `name`, `packageUrl`, `sdkName` and `mainDocsUrl` so the release registry entry is added to the registry for the first time (existing packages just need `onlyIfPresent` since the manifest already exists): +### Example: New SDK package + +A package uploaded to public registries (PyPI, NPM, etc.) ```yaml targets: @@ -73,25 +93,23 @@ targets: sdkName: 'sentry.javascript.wasm' packageUrl: 'https://www.npmjs.com/package/@sentry/wasm' mainDocsUrl: 'https://docs.sentry.io/platforms/javascript/' + # Optional fields for SDKs with API docs: + apiDocsUrl: 'https://pkg.go.dev/github.com/getsentry/sentry-go' ``` -## Manifest Metadata - -### `repo_url` - -The `repo_url` field is automatically set on every publish—it is not user-configurable per target. Craft resolves it in two ways: - -1. **Auto-detection (default):** Craft reads the `origin` git remote URL and extracts the owner and repo. Both HTTPS (`https://github.com/org/repo.git`) and SSH (`git@github.com:org/repo.git`) formats are supported. For most repositories, no configuration is needed. +### Example: New App with downloadable artifacts -2. **Explicit config (rare):** If auto-detection isn't possible (e.g., the remote is not on `github.com`), you can provide it via a top-level `github` block in `.craft.yml`: - ```yaml - github: - owner: getsentry - repo: sentry-javascript - ``` +A standalone application with version files in the registry -The value is always overwritten on every publish, so it stays in sync with the actual repository. - -### Other metadata - -When specified, the metadata fields (`name`, `sdkName`, `packageUrl`, `mainDocsUrl`, `apiDocsUrl`) are applied to every release, allowing you to update package metadata by changing your `.craft.yml` configuration. +```yaml +targets: + - name: registry + apps: + 'app:craft': + name: 'Sentry Craft' + mainDocsUrl: 'https://github.com/getsentry/craft' + urlTemplate: 'https://downloads.sentry-cdn.com/craft/{{version}}/{{file}}' + checksums: + - algorithm: sha256 + format: hex +``` diff --git a/src/utils/__tests__/registry.test.ts b/src/utils/__tests__/registry.test.ts index 44efbebb..771b6e87 100644 --- a/src/utils/__tests__/registry.test.ts +++ b/src/utils/__tests__/registry.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import * as fs from 'fs'; import * as path from 'path'; import { mkdtempSync, rmSync } from 'fs'; @@ -9,6 +9,7 @@ import { RegistryPackageType, InitialManifestData, } from '../registry'; +import { logger } from '../../logger'; describe('getPackageManifest', () => { let tempDir: string; @@ -124,6 +125,8 @@ describe('getPackageManifest', () => { const initialData: InitialManifestData = { canonical: 'npm:@sentry/minimal', repoUrl: 'https://github.com/getsentry/sentry-javascript', + name: 'Sentry Minimal', + // mainDocsUrl omitted — falls back to repoUrl }; const result = await getPackageManifest( @@ -137,6 +140,8 @@ describe('getPackageManifest', () => { expect(result.packageManifest).toEqual({ canonical: 'npm:@sentry/minimal', repo_url: 'https://github.com/getsentry/sentry-javascript', + name: 'Sentry Minimal', + main_docs_url: 'https://github.com/getsentry/sentry-javascript', }); }); @@ -158,6 +163,7 @@ describe('getPackageManifest', () => { canonical: 'app:craft', repoUrl: 'https://github.com/getsentry/craft', name: 'Craft', + // mainDocsUrl omitted — falls back to repoUrl }; const result = await getPackageManifest( @@ -176,6 +182,7 @@ describe('getPackageManifest', () => { canonical: 'app:craft', repo_url: 'https://github.com/getsentry/craft', name: 'Craft', + main_docs_url: 'https://github.com/getsentry/craft', }); }); @@ -183,6 +190,8 @@ describe('getPackageManifest', () => { const initialData: InitialManifestData = { canonical: 'npm:@sentry/core', repoUrl: 'https://github.com/getsentry/sentry-javascript', + name: 'Sentry Core', + mainDocsUrl: 'https://docs.sentry.io/platforms/javascript/', apiDocsUrl: 'https://docs.sentry.io/api/', }; @@ -197,6 +206,8 @@ describe('getPackageManifest', () => { expect(result.packageManifest).toEqual({ canonical: 'npm:@sentry/core', repo_url: 'https://github.com/getsentry/sentry-javascript', + name: 'Sentry Core', + main_docs_url: 'https://docs.sentry.io/platforms/javascript/', api_docs_url: 'https://docs.sentry.io/api/', }); }); @@ -208,6 +219,7 @@ describe('getPackageManifest', () => { canonical: 'npm:@sentry/hono', repoUrl: 'https://github.com/getsentry/sentry-javascript', name: 'Sentry Hono SDK', + packageUrl: 'https://www.npmjs.com/package/@sentry/hono', sdkName: 'sentry.javascript.hono', }; @@ -219,7 +231,11 @@ describe('getPackageManifest', () => { initialData, ); - const symlinkPath = path.join(tempDir, 'sdks', 'sentry.javascript.hono'); + const symlinkPath = path.join( + tempDir, + 'sdks', + 'sentry.javascript.hono', + ); expect(fs.existsSync(symlinkPath)).toBe(true); expect(fs.lstatSync(symlinkPath).isSymbolicLink()).toBe(true); expect(fs.readlinkSync(symlinkPath)).toBe( @@ -232,6 +248,7 @@ describe('getPackageManifest', () => { const initialData: InitialManifestData = { canonical: 'npm:@sentry/hono', repoUrl: 'https://github.com/getsentry/sentry-javascript', + name: 'Sentry Hono SDK', }; await getPackageManifest( @@ -248,7 +265,11 @@ describe('getPackageManifest', () => { it('skips symlink creation when the symlink already exists', async () => { fs.mkdirSync(path.join(tempDir, 'sdks'), { recursive: true }); - const symlinkPath = path.join(tempDir, 'sdks', 'sentry.javascript.hono'); + const symlinkPath = path.join( + tempDir, + 'sdks', + 'sentry.javascript.hono', + ); const existingTarget = path.join( '..', 'packages', @@ -261,6 +282,8 @@ describe('getPackageManifest', () => { const initialData: InitialManifestData = { canonical: 'npm:@sentry/hono', repoUrl: 'https://github.com/getsentry/sentry-javascript', + name: 'Sentry Hono SDK', + packageUrl: 'https://www.npmjs.com/package/@sentry/hono', sdkName: 'sentry.javascript.hono', }; @@ -310,5 +333,130 @@ describe('getPackageManifest', () => { expect(fs.readdirSync(sdksDir)).toHaveLength(0); }); }); + + describe('required field validation for all new packages', () => { + it('throws an error when name is missing', async () => { + const initialData: InitialManifestData = { + canonical: 'app:sentry-cli', + repoUrl: 'https://github.com/getsentry/sentry-cli', + // No name, no sdkName — error applies to all package types + }; + + await expect( + getPackageManifest( + tempDir, + RegistryPackageType.APP, + 'app:sentry-cli', + '1.0.0', + initialData, + ), + ).rejects.toThrow( + '"name" is required for new package "app:sentry-cli"', + ); + }); + + it('warns and falls back to repoUrl when mainDocsUrl is missing', async () => { + const warnSpy = vi.spyOn(logger, 'warn'); + const initialData: InitialManifestData = { + canonical: 'app:sentry-cli', + repoUrl: 'https://github.com/getsentry/sentry-cli', + name: 'Sentry CLI', + // mainDocsUrl omitted — applies to all package types + }; + + const result = await getPackageManifest( + tempDir, + RegistryPackageType.APP, + 'app:sentry-cli', + '1.0.0', + initialData, + ); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('"mainDocsUrl" is not set'), + ); + expect(result.packageManifest.main_docs_url).toBe( + 'https://github.com/getsentry/sentry-cli', + ); + warnSpy.mockRestore(); + }); + }); + + describe('additional SDK validation when sdkName is provided', () => { + it('throws an error when packageUrl is missing', async () => { + const initialData: InitialManifestData = { + canonical: 'npm:@sentry/new-sdk', + repoUrl: 'https://github.com/getsentry/sentry-javascript', + name: 'Sentry New SDK', + sdkName: 'sentry.javascript.new-sdk', + // packageUrl omitted + }; + + await expect( + getPackageManifest( + tempDir, + RegistryPackageType.SDK, + 'npm:@sentry/new-sdk', + '1.0.0', + initialData, + ), + ).rejects.toThrow( + '"packageUrl" is required for new SDK "npm:@sentry/new-sdk"', + ); + }); + + it('does not require packageUrl when sdkName is absent', async () => { + const initialData: InitialManifestData = { + canonical: 'npm:@sentry/minimal', + repoUrl: 'https://github.com/getsentry/sentry-javascript', + name: 'Sentry Minimal', + mainDocsUrl: 'https://docs.sentry.io/platforms/javascript/', + // No sdkName, no packageUrl — should not throw + }; + + const result = await getPackageManifest( + tempDir, + RegistryPackageType.SDK, + 'npm:@sentry/minimal', + '1.0.0', + initialData, + ); + + expect(result.packageManifest).toEqual({ + canonical: 'npm:@sentry/minimal', + repo_url: 'https://github.com/getsentry/sentry-javascript', + name: 'Sentry Minimal', + main_docs_url: 'https://docs.sentry.io/platforms/javascript/', + }); + }); + + it('creates initial manifest with all required fields for new SDK with sdkName', async () => { + fs.mkdirSync(path.join(tempDir, 'sdks'), { recursive: true }); + const initialData: InitialManifestData = { + canonical: 'npm:@sentry/new-sdk', + repoUrl: 'https://github.com/getsentry/sentry-javascript', + name: 'Sentry New SDK', + packageUrl: 'https://www.npmjs.com/package/@sentry/new-sdk', + mainDocsUrl: 'https://docs.sentry.io/platforms/javascript/', + sdkName: 'sentry.javascript.new-sdk', + }; + + const result = await getPackageManifest( + tempDir, + RegistryPackageType.SDK, + 'npm:@sentry/new-sdk', + '1.0.0', + initialData, + ); + + expect(result.packageManifest).toEqual({ + canonical: 'npm:@sentry/new-sdk', + repo_url: 'https://github.com/getsentry/sentry-javascript', + name: 'Sentry New SDK', + package_url: 'https://www.npmjs.com/package/@sentry/new-sdk', + main_docs_url: 'https://docs.sentry.io/platforms/javascript/', + }); + }); + }); }); }); diff --git a/src/utils/registry.ts b/src/utils/registry.ts index c880c3c8..d98d03c6 100644 --- a/src/utils/registry.ts +++ b/src/utils/registry.ts @@ -102,6 +102,37 @@ export async function getPackageManifest( throw new Error('Unreachable'); } + let effectiveManifestData = initialManifestData; + + // Required for all new packages + if (!initialManifestData.name) { + reportError( + `"name" is required for new package "${canonicalName}". ` + + `Add \`name\` to the registry target config in your .craft.yml.`, + ); + throw new Error('Unreachable'); + } + if (!initialManifestData.mainDocsUrl) { + logger.warn( + `"mainDocsUrl" is not set for "${canonicalName}". ` + + `Falling back to repo_url ("${initialManifestData.repoUrl}"). ` + + `Set \`mainDocsUrl\` in the registry target config in .craft.yml to avoid this warning.`, + ); + effectiveManifestData = { + ...initialManifestData, + mainDocsUrl: initialManifestData.repoUrl, + }; + } + + // Additional requirement for SDK packages + if (initialManifestData.sdkName && !initialManifestData.packageUrl) { + reportError( + `"packageUrl" is required for new SDK "${canonicalName}". ` + + `Add \`packageUrl\` to the registry target config in your .craft.yml.`, + ); + throw new Error('Unreachable'); + } + // Create directory structure if it doesn't exist if (!existsSync(fullPackageDir)) { logger.info( @@ -110,13 +141,17 @@ export async function getPackageManifest( mkdirSync(fullPackageDir, { recursive: true }); } - // Create the sdks/ symlink when an sdkName is provided - if (initialManifestData.sdkName) { - const sdkSymlinkPath = path.join(baseDir, 'sdks', initialManifestData.sdkName); + // Create the `sdks/` symlink when an sdkName is provided + if (effectiveManifestData.sdkName) { + const sdkSymlinkPath = path.join( + baseDir, + 'sdks', + effectiveManifestData.sdkName, + ); if (!existsSync(sdkSymlinkPath)) { const relativeTarget = path.join('..', packageDirPath); logger.info( - `Creating sdks symlink "${initialManifestData.sdkName}" -> "${relativeTarget}"...`, + `Creating sdks symlink "${effectiveManifestData.sdkName}" -> "${relativeTarget}"...`, ); symlinkSync(relativeTarget, sdkSymlinkPath); } @@ -127,11 +162,14 @@ export async function getPackageManifest( ); return { versionFilePath, - packageManifest: createInitialManifest(initialManifestData), + packageManifest: createInitialManifest(effectiveManifestData), }; } - logger.debug('Reading the current configuration from', packageManifestPath); + logger.debug( + 'Reading the already existing configuration from', + packageManifestPath, + ); return { versionFilePath, packageManifest: