projenrc/jsii.ts (354 lines of code) (raw):
import { yarn } from 'cdklabs-projen-project-types';
import * as pj from 'projen';
import { Stability } from 'projen/lib/cdk';
import { WorkflowSteps } from 'projen/lib/github';
import type { Job, Step, Tools } from 'projen/lib/github/workflows-model';
import { JobPermission } from 'projen/lib/github/workflows-model';
import { NodePackageManager } from 'projen/lib/javascript';
import type { CommonPublishOptions, NpmPublishOptions } from 'projen/lib/release';
export interface JsiiBuildOptions {
/**
* Publish to maven
* @default - no publishing
*/
readonly publishToMaven?: pj.cdk.JsiiJavaTarget;
/**
* Publish to pypi
* @default - no publishing
*/
readonly publishToPypi?: pj.cdk.JsiiPythonTarget;
/**
* Publish Go bindings to a git repository.
* @default - no publishing
*/
readonly publishToGo?: pj.cdk.JsiiGoTarget;
/**
* Publish to NuGet
* @default - no publishing
*/
readonly publishToNuget?: pj.cdk.JsiiDotNetTarget;
/**
* Automatically run API compatibility test against the latest version published to npm after compilation.
*
* - You can manually run compatibility tests using `yarn compat` if this feature is disabled.
* - You can ignore compatibility failures by adding lines to a ".compatignore" file.
*
* @default false
*/
readonly compat?: boolean;
/**
* Name of the ignore file for API compatibility tests.
*
* @default ".compatignore"
*/
readonly compatIgnore?: string;
/**
* Accepts a list of glob patterns. Files matching any of those patterns will be excluded from the TypeScript compiler input.
*
* By default, jsii will include all *.ts files (except .d.ts files) in the TypeScript compiler input.
* This can be problematic for example when the package's build or test procedure generates .ts files
* that cannot be compiled with jsii's compiler settings.
*/
readonly excludeTypescript?: string[];
/**
* File path for generated docs.
* @default "API.md"
*/
readonly docgenFilePath?: string;
/**
* Emit a compressed version of the assembly
* @default false
*/
readonly compressAssembly?: boolean;
/**
* Version of the jsii compiler to use.
*
* Set to "*" if you want to manually manage the version of jsii in your
* project by managing updates to `package.json` on your own.
*
* NOTE: The jsii compiler releases since 5.0.0 are not semantically versioned
* and should remain on the same minor, so we recommend using a `~` dependency
* (e.g. `~5.0.0`).
*
* @default "1.x"
* @pjnew "~5.5.0"
*/
readonly jsiiVersion?: string;
/**
* The default stability of the package
*
* @default Stable
*/
readonly stability?: pj.cdk.Stability;
/**
* Generate a MarkDown file describing the jsii API
*
* @default true
*/
readonly docgen?: boolean;
/**
* PyPI classifiers to add to `package.json`.
*
* @default none
*/
readonly pypiClassifiers?: string[];
/**
* Whether to turn on 'strict' mode for Rosetta
*
* @default false
*/
readonly rosettaStrict?: boolean;
/**
* Whether to turn on composite mode for the TypeScript project
*
* (Necessary in Monorepos)
*
* @default false
*/
readonly composite?: boolean;
}
/**
* Enable JSII building for a TypeScript project
*
* This class is mostly a straight-up copy/paste from
* <https://github.com/projen/projen/blob/main/src/cdk/jsii-project.ts>.
*
* It has to be this way because of inheritance.
*/
export class JsiiBuild extends pj.Component {
public readonly packageAllTask: pj.Task;
private readonly packageJsTask: pj.Task;
private readonly tsProject: pj.typescript.TypeScriptProject;
private readonly monoProject: yarn.TypeScriptWorkspace;
private readonly monorepoRelease: yarn.MonorepoRelease;
constructor(project: yarn.TypeScriptWorkspace, options: JsiiBuildOptions) {
super(project);
this.monoProject = project;
if (!(project instanceof pj.typescript.TypeScriptProject)) {
throw new Error('JsiiBuild() must be passed a TypeScript project');
}
if (!project.parent || !yarn.Monorepo.isMonorepo(project.parent)) {
throw new Error('Project root must be Monorepo component');
}
if (!project.parent.monorepoRelease) {
throw new Error('Monorepo does not have a release component');
}
this.monorepoRelease = project.parent.monorepoRelease;
const tsProject = project;
this.tsProject = tsProject;
if (tsProject.tsconfig) {
throw new Error('The TypeScript project for JsiiBuild() must be configured with { disableTsconfig: true }');
}
if ((tsProject.release?.publisher as any)?.publishJobs?.npm) {
throw new Error('The TypeScript project for JsiiBuild() must be configured without an NPM publishing job');
}
const srcdir = tsProject.srcdir;
const libdir = tsProject.libdir;
tsProject.addFields({ types: `${libdir}/index.d.ts` });
const compressAssembly = options.compressAssembly ?? false;
// this is an unhelpful warning
const jsiiFlags = ['--silence-warnings=reserved-word'];
if (compressAssembly) {
jsiiFlags.push('--compress-assembly');
}
const compatIgnore = options.compatIgnore ?? '.compatignore';
tsProject.addFields({ stability: options.stability ?? Stability.STABLE });
if (options.stability === Stability.DEPRECATED) {
tsProject.addFields({ deprecated: true });
}
const compatTask = tsProject.addTask('compat', {
description: 'Perform API compatibility check against latest version',
exec: `jsii-diff npm:$(node -p "require(\'./package.json\').name") -k --ignore-file ${compatIgnore} || (echo "\nUNEXPECTED BREAKING CHANGES: add keys such as \'removed:constructs.Node.of\' to ${compatIgnore} to skip.\n" && exit 1)`,
});
const compat = options.compat ?? false;
if (compat) {
tsProject.compileTask.spawn(compatTask);
}
tsProject.compileTask.reset(['jsii', ...jsiiFlags].join(' '));
tsProject.watchTask.reset(['jsii', '-w', ...jsiiFlags].join(' '));
// Create a new package:all task, it will be filled with language targets later
this.packageAllTask = tsProject.addTask('package-all', {
description: 'Packages artifacts for all target languages',
});
// in jsii we consider the entire repo (post build) as the build artifact
// which is then used to create the language bindings in separate jobs.
// we achieve this by doing a checkout and overwrite with the files from the js package.
this.packageJsTask = this.addPackagingTask('js');
// When running inside CI we initially only package js. Other targets are packaged in separate jobs.
// Outside of CI (i.e locally) we simply package all targets.
tsProject.packageTask.reset();
tsProject.packageTask.spawn(this.packageJsTask, {
// Only run in CI
condition: 'node -e "if (!process.env.CI) process.exit(1)"',
});
// Do not spawn 'package-all' automatically as part of 'package', the jsii packaging will
// be done as part of the release task.
/*
tsProject.packageTask.spawn(this.packageAllTask, {
// Don't run in CI
condition: 'node -e "if (process.env.CI) process.exit(1)"',
});
*/
const targets: Record<string, any> = {};
const jsii: any = {
outdir: tsProject.artifactsDirectory,
targets,
tsc: {
outDir: libdir,
rootDir: srcdir,
},
};
if (options.excludeTypescript) {
jsii.excludeTypescript = options.excludeTypescript;
}
if (options.composite) {
jsii.projectReferences = true;
}
tsProject.addFields({ jsii });
// FIXME: Not support "runsOn" and the workflow container image for now
const extraJobOptions: Partial<Job> = {
/*
...this.getJobRunsOnConfig(options),
...(options.workflowContainerImage
? { container: { image: options.workflowContainerImage } }
: {}),
*/
};
const npmjs: NpmPublishOptions = {
registry: tsProject.package.npmRegistry,
npmTokenSecret: tsProject.package.npmTokenSecret,
npmProvenance: tsProject.package.npmProvenance,
// No support for CodeArtifact here
// codeArtifactOptions: tsProject.codeArtifactOptions,
};
this.addTargetToBuild('js', this.packageJsTask, extraJobOptions);
this.addTargetToRelease('js', this.packageJsTask, npmjs);
const maven = options.publishToMaven;
if (maven) {
targets.java = {
package: maven.javaPackage,
maven: {
groupId: maven.mavenGroupId,
artifactId: maven.mavenArtifactId,
},
};
const task = this.addPackagingTask('java');
this.addTargetToBuild('java', task, extraJobOptions);
this.addTargetToRelease('java', task, maven);
}
const pypi = options.publishToPypi;
if (pypi) {
targets.python = {
distName: pypi.distName,
module: pypi.module,
};
const task = this.addPackagingTask('python');
this.addTargetToBuild('python', task, extraJobOptions);
this.addTargetToRelease('python', task, pypi);
}
const nuget = options.publishToNuget;
if (nuget) {
targets.dotnet = {
namespace: nuget.dotNetNamespace,
packageId: nuget.packageId,
iconUrl: nuget.iconUrl,
};
const task = this.addPackagingTask('dotnet');
this.addTargetToBuild('dotnet', task, extraJobOptions);
this.addTargetToRelease('dotnet', task, nuget);
}
const golang = options.publishToGo;
if (golang) {
targets.go = {
moduleName: golang.moduleName,
packageName: golang.packageName,
versionSuffix: golang.versionSuffix,
};
const task = this.addPackagingTask('go');
this.addTargetToBuild('go', task, extraJobOptions);
this.addTargetToRelease('go', task, golang);
}
const jsiiSuffix =
options.jsiiVersion === '*'
? // If jsiiVersion is "*", don't specify anything so the user can manage.
''
: // Otherwise, use `jsiiVersion` or fall back to `5.7`
`@${options.jsiiVersion ?? '5.7'}`;
tsProject.addDevDeps(
`jsii${jsiiSuffix}`,
`jsii-rosetta${jsiiSuffix}`,
'jsii-diff',
'jsii-pacmak',
);
tsProject.gitignore.exclude('.jsii', 'tsconfig.json');
tsProject.npmignore?.include('.jsii');
if (options.docgen ?? true) {
// If jsiiVersion is "*", don't specify anything so the user can manage.
// Otherwise use a version that is compatible with all supported jsii releases.
const docgenVersion = options.jsiiVersion === '*' ? '*' : '^10.5.0';
new pj.cdk.JsiiDocgen(tsProject, {
version: docgenVersion,
filePath: options.docgenFilePath,
});
}
// jsii updates .npmignore, so we make it writable
if (tsProject.npmignore) {
tsProject.npmignore.readonly = false;
}
const packageJson = tsProject.package.file;
if ((options.pypiClassifiers ?? []).length > 0) {
packageJson.patch(
pj.JsonPatch.add('/jsii/targets/python/classifiers', options.pypiClassifiers),
);
}
if (options.rosettaStrict) {
packageJson.patch(
pj.JsonPatch.add('/jsii/metadata', {}),
pj.JsonPatch.add('/jsii/metadata/jsii', {}),
pj.JsonPatch.add('/jsii/metadata/jsii/rosetta', {}),
pj.JsonPatch.add('/jsii/metadata/jsii/rosetta/strict', true),
);
}
}
/**
* Adds a target language to the release workflow.
*/
private addTargetToRelease(
language: JsiiPacmakTarget,
packTask: pj.Task,
target:
| pj.cdk.JsiiPythonTarget
| pj.cdk.JsiiDotNetTarget
| pj.cdk.JsiiGoTarget
| pj.cdk.JsiiJavaTarget
| NpmPublishOptions,
) {
const release = this.monorepoRelease.workspaceRelease(this.monoProject);
const pacmak = this.pacmakForLanguage(language, packTask);
const prePublishSteps = [
...pacmak.bootstrapSteps,
pj.github.WorkflowSteps.checkout({
with: {
path: REPO_TEMP_DIRECTORY,
...(this.tsProject.github?.downloadLfs ? { lfs: true } : {}),
},
}),
...pacmak.packagingSteps,
];
const commonPublishOptions: CommonPublishOptions = {
publishTools: pacmak.publishTools,
prePublishSteps,
};
const handler: PublishTo = publishTo[language];
release.publisher?.[handler]({
...commonPublishOptions,
...target,
});
}
/**
* Adds a target language to the build workflow
*/
private addTargetToBuild(
language: JsiiPacmakTarget,
packTask: pj.Task,
extraJobOptions: Partial<Job>,
) {
if (!this.tsProject.buildWorkflow) {
return;
}
const pacmak = this.pacmakForLanguage(language, packTask);
this.tsProject.buildWorkflow.addPostBuildJob(`package-${language}`, {
...pj.filteredRunsOnOptions(
extraJobOptions.runsOn,
extraJobOptions.runsOnGroup,
),
permissions: {
contents: JobPermission.READ,
},
tools: {
// FIXME: We should get this from a global GitHub component
node: { version: (this.tsProject as any).nodeVersion ?? 'lts/*' },
...pacmak.publishTools,
},
steps: [
...pacmak.bootstrapSteps,
WorkflowSteps.checkout({
with: {
path: REPO_TEMP_DIRECTORY,
ref: PULL_REQUEST_REF,
repository: PULL_REQUEST_REPOSITORY,
...(this.tsProject.github?.downloadLfs ? { lfs: true } : {}),
},
}),
...pacmak.packagingSteps,
],
...extraJobOptions,
});
}
private addPackagingTask(language: JsiiPacmakTarget): pj.Task {
const packageTargetTask = this.tsProject.tasks.addTask(`package:${language}`, {
description: `Create ${language} language bindings`,
});
const commandParts = ['jsii-pacmak', '-v'];
if (this.tsProject.package.packageManager === NodePackageManager.PNPM) {
commandParts.push("--pack-command 'pnpm pack'");
}
commandParts.push(`--target ${language}`);
packageTargetTask.exec(commandParts.join(' '));
this.packageAllTask.spawn(packageTargetTask);
return packageTargetTask;
}
private pacmakForLanguage(
target: JsiiPacmakTarget,
packTask: pj.Task,
): {
publishTools: Tools;
bootstrapSteps: Array<Step>;
packagingSteps: Array<Step>;
} {
const bootstrapSteps: Array<Step> = [];
const packagingSteps: Array<Step> = [];
// Generic bootstrapping for all target languages
bootstrapSteps.push(...(this.tsProject as any).workflowBootstrapSteps);
if (this.tsProject.package.packageManager === NodePackageManager.PNPM) {
bootstrapSteps.push({
name: 'Setup pnpm',
uses: 'pnpm/action-setup@v3',
with: { version: this.tsProject.package.pnpmVersion },
});
} else if (this.tsProject.package.packageManager === NodePackageManager.BUN) {
bootstrapSteps.push({
name: 'Setup bun',
uses: 'oven-sh/setup-bun@v1',
});
}
// Installation steps before packaging, but after checkout
packagingSteps.push(
{
name: 'Install Dependencies',
run: `cd ${REPO_TEMP_DIRECTORY} && ${this.tsProject.package.installCommand}`,
},
{
name: 'Extract build artifact',
run: `tar --strip-components=1 -xzvf ${this.tsProject.artifactsDirectory}/js/*.tgz -C ${REPO_TEMP_DIRECTORY}/${this.monoProject.workspaceDirectory}`,
},
{
name: 'Move build artifact out of the way',
run: `mv ${this.tsProject.artifactsDirectory} ${BUILD_ARTIFACT_OLD_DIR}`,
},
{
name: `Create ${target} artifact`,
run: `cd ${REPO_TEMP_DIRECTORY}/${this.monoProject.workspaceDirectory} && ${this.tsProject.runTaskCommand(packTask)}`,
},
{
name: `Collect ${target} artifact`,
run: `mv ${REPO_TEMP_DIRECTORY}/${this.monoProject.workspaceDirectory}/${this.tsProject.artifactsDirectory} ${this.tsProject.artifactsDirectory}`,
},
);
return {
publishTools: JSII_TOOLCHAIN[target],
bootstrapSteps,
packagingSteps,
};
}
}
type PublishTo = keyof pj.release.Publisher &
(
| 'publishToNpm'
| 'publishToMaven'
| 'publishToPyPi'
| 'publishToNuget'
| 'publishToGo'
);
type PublishToTarget = { [K in JsiiPacmakTarget]: PublishTo };
const publishTo: PublishToTarget = {
js: 'publishToNpm',
java: 'publishToMaven',
python: 'publishToPyPi',
dotnet: 'publishToNuget',
go: 'publishToGo',
};
export type JsiiPacmakTarget = 'js' | 'go' | 'java' | 'python' | 'dotnet';
/**
* GitHub workflow job steps for setting up the tools required for various JSII targets.
*/
export const JSII_TOOLCHAIN: Record<JsiiPacmakTarget, Tools> = {
js: {},
java: { java: { version: '11' } },
python: { python: { version: '3.x' } },
go: { go: { version: '^1.18.0' } },
dotnet: { dotnet: { version: '6.x' } },
};
const REPO_TEMP_DIRECTORY = '.repo';
const BUILD_ARTIFACT_OLD_DIR = 'dist.old';
export const PULL_REQUEST_REF = '${{ github.event.pull_request.head.ref }}';
export const PULL_REQUEST_REPOSITORY =
'${{ github.event.pull_request.head.repo.full_name }}';