Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/release-with-changesets.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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`
4 changes: 2 additions & 2 deletions docs/optimizer/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

---

Expand Down Expand Up @@ -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 |
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@
"engines": {
"node": ">=24.0.0"
},
"packageManager": "npm@11.6.2",
"files": [
"/bin",
"/lib",
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/optimizer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
243 changes: 243 additions & 0 deletions scripts/publish-trusted.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
'use strict';

/**
* Used as changesets/action `publish`. Tags unpublished workspace packages,
* creates the CLI GitHub Release (`v<version>`), and prints
* `New tag: <name>@<version>` 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();
2 changes: 1 addition & 1 deletion turbo.json
Original file line number Diff line number Diff line change
@@ -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"] },
Expand Down
Loading