diff --git a/.github/workflows/release-with-changesets.yml b/.github/workflows/release-with-changesets.yml index ce5258ea3..a46f395b9 100644 --- a/.github/workflows/release-with-changesets.yml +++ b/.github/workflows/release-with-changesets.yml @@ -117,15 +117,15 @@ jobs: - if: steps.packagejson.outputs.exists == 'true' name: Publish to any of NPM, Github, and Docker Hub # If changeset files exist, this opens a Version Packages PR (runs bump:version, including bump:github-action). - # If not (Version PR was merged), publish:trusted only creates the git tag; the next step publishes to npm. + # If not (Version PR was merged), publish:trusted tags and the next step publishes to npm. uses: changesets/action@e0145edc7d9d8679003495b11f87bd8ef63c0cba # v1.5.3 id: release with: version: npm run bump:version commit: "chore(release): release and bump versions of packages" title: "chore(release): release and bump versions of packages" - # Tag-only workaround: changesets/action does not support OIDC npm publish yet publish: npm run publish:trusted + createGithubReleases: false setupGitUser: false env: GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} diff --git a/docs/architecture.md b/docs/architecture.md index ef3f5ee89..db0f9cff7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -164,4 +164,4 @@ and also versions workspace packages such as `@asyncapi/optimizer`. Additional p Turbo orders builds so `@asyncapi/optimizer` is built before the root CLI (whose build compiles the `optimize` command that imports it). Use `npm run optimizer:build` / `npm run optimizer:test` to work on it -in isolation. See [`docs/optimizer/spec.md`](/docs/optimizer/spec.md). +in isolation. See the package spec on GitHub: [`docs/optimizer/spec.md`](https://github.com/asyncapi/cli/blob/master/docs/optimizer/spec.md). diff --git a/docs/optimizer-migration.md b/docs/optimizer/optimizer-migration.md similarity index 93% rename from docs/optimizer-migration.md rename to docs/optimizer/optimizer-migration.md index ecb684291..910e482bf 100644 --- a/docs/optimizer-migration.md +++ b/docs/optimizer/optimizer-migration.md @@ -1,7 +1,10 @@ # Migrating `@asyncapi/optimizer` into the CLI (and the v2 release) > A plain-language guide for anyone new to this codebase. It explains what moved, why, and what (if anything) -> you need to change as a consumer. For the precise package reference, see [`docs/optimizer/spec.md`](/docs/optimizer/spec.md). +> you need to change as a consumer. For the precise package reference, see [`spec.md`](./spec.md). +> +> This file lives under `docs/optimizer/` (not `docs/*.md`) so it is **not** copied to the AsyncAPI website. +> The website CLI docs are for people using the CLI; this guide is for maintainers and npm library consumers. ## What changed, in one paragraph @@ -92,6 +95,6 @@ npm run build # full CLI build (builds optimizer first) ## References -- Package spec: [`docs/optimizer/spec.md`](/docs/optimizer/spec.md) +- Package spec: [`spec.md`](./spec.md) - Tracking issue: [optimizer#306](https://github.com/asyncapi/optimizer/issues/306) - Prior art (same pattern): `@asyncapi/openapi-schema-parser` into `asyncapi/parser-js` diff --git a/docs/optimizer/spec.md b/docs/optimizer/spec.md index 362948712..6280709d4 100644 --- a/docs/optimizer/spec.md +++ b/docs/optimizer/spec.md @@ -6,7 +6,7 @@ This document describes what the package is, what problem it solves, how it works, how to develop and release it inside the `asyncapi/cli` monorepo, and the v2 contract. Consumer-facing install/usage examples live in [`packages/optimizer/README.md`](/packages/optimizer/README.md). A beginner-oriented migration walkthrough -lives in [`docs/optimizer-migration.md`](/docs/optimizer-migration.md). +lives in [`optimizer-migration.md`](./optimizer-migration.md). --- @@ -199,7 +199,7 @@ publish. Package metadata: `private: false`, `publishConfig.access: public`. | Source | [`packages/optimizer/src/`](/packages/optimizer/src) | | Tests | [`packages/optimizer/test/`](/packages/optimizer/test) | | npm README | [`packages/optimizer/README.md`](/packages/optimizer/README.md) | -| Migration guide | [`docs/optimizer-migration.md`](/docs/optimizer-migration.md) | +| Migration guide | [`optimizer-migration.md`](./optimizer-migration.md) | | Exit-code convention | [`codes_reference-cleanup-docs.md`](/codes_reference-cleanup-docs.md) | | Release workflow | [`.github/workflows/release-with-changesets.yml`](/.github/workflows/release-with-changesets.yml) | | Tracking issue | https://github.com/asyncapi/optimizer/issues/306 | diff --git a/package.json b/package.json index d4e560c98..b27986e76 100644 --- a/package.json +++ b/package.json @@ -107,6 +107,7 @@ "engines": { "node": ">=24.0.0" }, + "packageManager": "npm@11.6.2", "files": [ "/bin", "/lib", @@ -185,7 +186,7 @@ "pack:tarballs:alpine": "oclif pack tarballs -t linux-x64 && npm run pack:rename alpine", "pack:windows": "oclif pack win && npm run pack:rename", "pack:rename": "node scripts/releasePackagesRename.js", - "publish:trusted": "echo \"Working around changesets action not supporting OIDC yet. Need to pass successful release output for triggering github release\";VERSION=$(node -p \"require('./package.json').version\"); PACKAGE_NAME=$(node -p \"require('./package.json').name\"); if npm view $PACKAGE_NAME@$VERSION > /dev/null 2>&1; then echo \"Version $VERSION of package $PACKAGE_NAME already published to NPM. Skipping GitHub release.\"; else git tag v$VERSION -m v$VERSION; echo \"New tag: $PACKAGE_NAME@$VERSION.\"; fi", + "publish:trusted": "node scripts/publish-trusted.js", "prepublishOnly": "npm run build", "pretest": "npm run build", "pretest:coverage": "npm run build", diff --git a/packages/optimizer/README.md b/packages/optimizer/README.md index 38dffe281..a1273b652 100644 --- a/packages/optimizer/README.md +++ b/packages/optimizer/README.md @@ -9,7 +9,7 @@ AsyncAPI offers many ways to reuse certain parts of the document like messages o > `.code` (`OptimizerError` / `OptimizerErrorCode`) instead of plain `Error` + `console.error`; `getReport()` > now returns `{ type, elements }[]`; and `@asyncapi/parser` is now a **peerDependency** (install it alongside). > The optimization algorithm and `getOptimizedDocument()` behaviour are unchanged. See the migration guide: -> [`docs/optimizer-migration.md`](https://github.com/asyncapi/cli/blob/master/docs/optimizer-migration.md). +> [`docs/optimizer/optimizer-migration.md`](https://github.com/asyncapi/cli/blob/master/docs/optimizer/optimizer-migration.md). ![npm](https://img.shields.io/npm/v/@asyncapi/optimizer?style=for-the-badge) ![npm](https://img.shields.io/npm/dt/@asyncapi/optimizer?style=for-the-badge) diff --git a/scripts/publish-trusted.js b/scripts/publish-trusted.js new file mode 100644 index 000000000..79da10374 --- /dev/null +++ b/scripts/publish-trusted.js @@ -0,0 +1,243 @@ +'use strict'; + +/** + * Used as changesets/action `publish`. Tags unpublished workspace packages, + * creates the CLI GitHub Release (`v`), and prints + * `New tag: @` for each. npm publish is the next workflow step. + */ + +const fs = require('node:fs'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const GIT = '/usr/bin/git'; +const GH = '/usr/bin/gh'; + +const ROOT_DIR = path.resolve(__dirname, '..'); +const MAX_WORKSPACE_PATTERNS = 16; +const MAX_PACKAGES_PER_GLOB = 32; +const MAX_PACKAGE_NAME_LENGTH = 214; +const MAX_VERSION_LENGTH = 64; +const PACKAGE_NAME_PATTERN = /^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/; +const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?$/; +const WORKSPACE_GLOB_PATTERN = /^(?:\.|[a-zA-Z0-9._-]+\/\*)$/; + +function main() { + const rootPackage = readPackageJson(ROOT_DIR); + const workspacePatterns = Array.isArray(rootPackage.workspaces) ? rootPackage.workspaces : ['.']; + const packages = listWorkspacePackages(ROOT_DIR, workspacePatterns); + const unpublished = []; + + for (const pkg of packages) { + if (pkg.private) { + continue; + } + if (isVersionOnNpm(pkg.name, pkg.version)) { + console.log(`Version ${pkg.version} of package ${pkg.name} already published to NPM. Skipping.`); + continue; + } + unpublished.push(pkg); + } + + if (unpublished.length === 0) { + console.log('All workspace package versions are already on NPM. Skipping GitHub release.'); + return; + } + + const cliPackage = unpublished.find((pkg) => pkg.dir === ROOT_DIR); + if (cliPackage) { + const cliTag = `v${cliPackage.version}`; + createGitTag(cliTag); + createGithubRelease(cliTag); + } + + for (const pkg of unpublished) { + console.log(`New tag: ${pkg.name}@${pkg.version}`); + } +} + +function listWorkspacePackages(rootDir, workspacePatterns) { + if (workspacePatterns.length > MAX_WORKSPACE_PATTERNS) { + throw new Error(`Too many workspace patterns (max ${MAX_WORKSPACE_PATTERNS}).`); + } + + const packages = []; + const seenDirs = new Set(); + + for (const pattern of workspacePatterns) { + if (typeof pattern !== 'string' || !WORKSPACE_GLOB_PATTERN.test(pattern)) { + throw new Error(`Unsupported workspace pattern: ${String(pattern)}`); + } + + const dirs = resolveWorkspacePattern(rootDir, pattern); + for (const dir of dirs) { + if (seenDirs.has(dir)) { + continue; + } + seenDirs.add(dir); + packages.push(readWorkspacePackage(dir)); + } + } + + return packages; +} + +function resolveWorkspacePattern(rootDir, pattern) { + if (pattern === '.') { + return [rootDir]; + } + + const parentName = pattern.slice(0, -2); + const parentDir = resolveWithinRoot(rootDir, path.join(rootDir, parentName)); + if (!fs.existsSync(parentDir) || !fs.statSync(parentDir).isDirectory()) { + return []; + } + + const entries = fs.readdirSync(parentDir); + if (entries.length > MAX_PACKAGES_PER_GLOB) { + throw new Error(`Too many entries in ${parentName}/ (max ${MAX_PACKAGES_PER_GLOB}).`); + } + + const dirs = []; + for (const entry of entries) { + const candidate = resolveWithinRoot(rootDir, path.join(parentDir, entry)); + const packageJsonPath = path.join(candidate, 'package.json'); + if (fs.existsSync(packageJsonPath) && fs.statSync(candidate).isDirectory()) { + dirs.push(candidate); + } + } + return dirs; +} + +function readWorkspacePackage(dir) { + const packageJson = readPackageJson(dir); + const name = assertPackageName(packageJson.name); + const version = assertVersion(packageJson.version); + return { + dir, + name, + version, + private: packageJson.private === true, + }; +} + +function readPackageJson(dir) { + const packageJsonPath = path.join(dir, 'package.json'); + const raw = fs.readFileSync(packageJsonPath, { encoding: 'utf8' }); + return JSON.parse(raw); +} + +function resolveWithinRoot(rootDir, targetPath) { + const root = path.resolve(rootDir); + const resolved = path.resolve(targetPath); + if (resolved !== root && !resolved.startsWith(root + path.sep)) { + throw new Error('Path escapes workspace root.'); + } + return resolved; +} + +function assertPackageName(name) { + if (typeof name !== 'string' || name.length === 0 || name.length > MAX_PACKAGE_NAME_LENGTH) { + throw new Error('Invalid package name length.'); + } + if (!PACKAGE_NAME_PATTERN.test(name)) { + throw new Error(`Invalid package name: ${name}`); + } + return name; +} + +function assertVersion(version) { + if (typeof version !== 'string' || version.length === 0 || version.length > MAX_VERSION_LENGTH) { + throw new Error('Invalid package version length.'); + } + if (!VERSION_PATTERN.test(version)) { + throw new Error(`Invalid package version: ${version}`); + } + return version; +} + +function isVersionOnNpm(name, version) { + const npmCli = path.join(path.dirname(process.execPath), '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'); + if (!fs.existsSync(npmCli)) { + throw new Error('npm CLI not found next to the Node executable.'); + } + const result = spawnSync(process.execPath, [npmCli, 'view', `${name}@${version}`, 'version'], { + encoding: 'utf8', + cwd: ROOT_DIR, + stdio: ['ignore', 'pipe', 'pipe'], + }); + return result.status === 0 && result.stdout.trim() === version; +} + +function gitRefExists(ref) { + const result = spawnSync(GIT, ['rev-parse', '-q', '--verify', ref], { + cwd: ROOT_DIR, + stdio: 'ignore', + }); + return result.status === 0; +} + +function createGitTag(tag) { + assertCliGitTag(tag); + if (gitRefExists(`refs/tags/${tag}`)) { + console.log(`Git tag ${tag} already exists.`); + return; + } + + const result = spawnSync(GIT, ['tag', tag, '-m', tag], { + cwd: ROOT_DIR, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + if (result.status !== 0) { + throw new Error(`Failed to create git tag ${tag}: ${result.stderr || result.stdout}`); + } + console.log(`Created git tag ${tag}.`); +} + +function assertCliGitTag(tag) { + if (typeof tag !== 'string' || tag.length > MAX_VERSION_LENGTH + 1 || !/^v\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?$/.test(tag)) { + throw new Error(`Invalid CLI git tag: ${String(tag)}`); + } +} + +function createGithubRelease(tag) { + assertCliGitTag(tag); + + const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN || ''; + if (!token) { + console.log('GH_TOKEN not set; skipping GitHub Release creation.'); + return; + } + + const ghCheck = spawnSync(GH, ['--version'], { stdio: 'ignore' }); + if (ghCheck.status !== 0) { + console.log('gh CLI not available; skipping GitHub Release creation.'); + return; + } + + const env = { ...process.env, GH_TOKEN: token }; + const existing = spawnSync(GH, ['release', 'view', tag], { + cwd: ROOT_DIR, + encoding: 'utf8', + env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + if (existing.status === 0) { + console.log(`GitHub Release ${tag} already exists.`); + return; + } + + const created = spawnSync(GH, ['release', 'create', tag, '--title', tag, '--generate-notes'], { + cwd: ROOT_DIR, + encoding: 'utf8', + env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + if (created.status !== 0) { + throw new Error(`Failed to create GitHub Release ${tag}: ${created.stderr || created.stdout}`); + } + console.log(`Created GitHub Release ${tag}.`); +} + +main(); diff --git a/turbo.json b/turbo.json index 964628e4b..3dc4910c5 100644 --- a/turbo.json +++ b/turbo.json @@ -1,6 +1,6 @@ { "$schema": "https://turbo.build/schema.json", - "pipeline": { + "tasks": { "build": { "cache": false }, "@asyncapi/optimizer#build": { "cache": false }, "test": { "cache": false, "dependsOn": ["build"] },