mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-10 08:05:14 +02:00
chore: add semantic release workflow
This commit is contained in:
parent
5073a76a5b
commit
a11f7a06ae
18 changed files with 2822 additions and 56 deletions
|
|
@ -6,10 +6,15 @@ import { dirname, join, resolve } from 'node:path';
|
|||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import {
|
||||
PUBLIC_NPM_PACKAGE_NAME,
|
||||
publicNpmPackageVersion,
|
||||
} from './public-npm-release-metadata.mjs';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export const PUBLIC_NPM_PACKAGE_NAME = '@kaelio/ktx';
|
||||
export const PUBLIC_NPM_PACKAGE_VERSION = '0.1.0-rc.1';
|
||||
export const PUBLIC_NPM_PACKAGE_VERSION = publicNpmPackageVersion();
|
||||
export { PUBLIC_NPM_PACKAGE_NAME };
|
||||
|
||||
export function publicNpmPackageTarballName(version = PUBLIC_NPM_PACKAGE_VERSION) {
|
||||
return `kaelio-ktx-${version}.tgz`;
|
||||
|
|
|
|||
|
|
@ -14,9 +14,9 @@ import {
|
|||
} from './build-python-runtime-wheel.mjs';
|
||||
import {
|
||||
PUBLIC_NPM_PACKAGE_NAME,
|
||||
PUBLIC_NPM_PACKAGE_VERSION,
|
||||
publicNpmPackageTarballName,
|
||||
} from './build-public-npm-package.mjs';
|
||||
import { publicNpmPackageVersion } from './public-npm-release-metadata.mjs';
|
||||
|
||||
export {
|
||||
RUNTIME_WHEEL_DISTRIBUTION_NAME,
|
||||
|
|
@ -45,24 +45,27 @@ function scriptRootDir() {
|
|||
return resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
}
|
||||
|
||||
function npmPackageTarballName(packageName) {
|
||||
function npmPackageTarballName(packageName, version) {
|
||||
if (packageName !== PUBLIC_NPM_PACKAGE_NAME) {
|
||||
throw new Error(`Unsupported npm artifact package: ${packageName}`);
|
||||
}
|
||||
return publicNpmPackageTarballName(PUBLIC_NPM_PACKAGE_VERSION);
|
||||
return publicNpmPackageTarballName(version);
|
||||
}
|
||||
|
||||
function npmPackageTarballs(npmDir) {
|
||||
function npmPackageTarballs(npmDir, version) {
|
||||
return Object.fromEntries(
|
||||
NPM_ARTIFACT_PACKAGES.map((packageInfo) => [packageInfo.name, join(npmDir, npmPackageTarballName(packageInfo.name))]),
|
||||
NPM_ARTIFACT_PACKAGES.map((packageInfo) => [
|
||||
packageInfo.name,
|
||||
join(npmDir, npmPackageTarballName(packageInfo.name, version)),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
export function packageArtifactLayout(rootDir = scriptRootDir()) {
|
||||
export function packageArtifactLayout(rootDir = scriptRootDir(), version = publicNpmPackageVersion(rootDir)) {
|
||||
const artifactDir = join(rootDir, 'dist', 'artifacts');
|
||||
const npmDir = join(artifactDir, 'npm');
|
||||
const pythonDir = join(artifactDir, 'python');
|
||||
const npmTarballs = npmPackageTarballs(npmDir);
|
||||
const npmTarballs = npmPackageTarballs(npmDir, version);
|
||||
|
||||
return {
|
||||
rootDir,
|
||||
|
|
@ -170,7 +173,7 @@ function releaseMetadataEntry({ ecosystem, packageName, packageRoot, packageVers
|
|||
};
|
||||
}
|
||||
|
||||
async function readNpmPackageMetadata(rootDir, packageInfo) {
|
||||
async function readNpmPackageMetadata(rootDir, packageInfo, version) {
|
||||
const packageJson = await readJson(join(rootDir, packageInfo.packageRoot, 'package.json'));
|
||||
const expectedSourceName = packageInfo.name === PUBLIC_NPM_PACKAGE_NAME ? '@ktx/cli' : packageInfo.name;
|
||||
if (packageJson.name !== expectedSourceName) {
|
||||
|
|
@ -183,14 +186,14 @@ async function readNpmPackageMetadata(rootDir, packageInfo) {
|
|||
ecosystem: 'npm',
|
||||
packageName: packageInfo.name,
|
||||
packageRoot: packageInfo.packageRoot,
|
||||
packageVersion: isPublicKtxPackage ? PUBLIC_NPM_PACKAGE_VERSION : packageJson.version,
|
||||
packageVersion: isPublicKtxPackage ? version : packageJson.version,
|
||||
privatePackage: isPublicKtxPackage ? false : packageJson.private === true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function packageReleaseMetadata(rootDir = scriptRootDir()) {
|
||||
export async function packageReleaseMetadata(rootDir = scriptRootDir(), version = publicNpmPackageVersion(rootDir)) {
|
||||
const npmPackages = await Promise.all(
|
||||
NPM_ARTIFACT_PACKAGES.map((packageInfo) => readNpmPackageMetadata(rootDir, packageInfo)),
|
||||
NPM_ARTIFACT_PACKAGES.map((packageInfo) => readNpmPackageMetadata(rootDir, packageInfo, version)),
|
||||
);
|
||||
|
||||
return [
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { tmpdir } from 'node:os';
|
|||
import { join } from 'node:path';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
import { PUBLIC_NPM_PACKAGE_VERSION } from './build-public-npm-package.mjs';
|
||||
import {
|
||||
CLI_PYTHON_ASSET_MANIFEST,
|
||||
INTERNAL_NPM_WORKSPACE_PACKAGES,
|
||||
|
|
@ -32,6 +33,35 @@ async function writeJson(path, value) {
|
|||
}
|
||||
|
||||
async function writeReleaseMetadataInputs(root) {
|
||||
await writeJson(join(root, 'release-policy.json'), {
|
||||
schemaVersion: 1,
|
||||
publicNpmPackageVersion: PUBLIC_NPM_PACKAGE_VERSION,
|
||||
releaseMode: 'ci-artifact-only',
|
||||
npm: {
|
||||
publish: false,
|
||||
registry: null,
|
||||
access: 'public',
|
||||
tag: 'next',
|
||||
packages: ['@kaelio/ktx'],
|
||||
},
|
||||
python: {
|
||||
publish: false,
|
||||
repository: null,
|
||||
packages: ['kaelio-ktx'],
|
||||
},
|
||||
publishedPackageSmoke: {
|
||||
packageName: '@kaelio/ktx',
|
||||
version: PUBLIC_NPM_PACKAGE_VERSION,
|
||||
registry: null,
|
||||
},
|
||||
runtimeInstaller: {
|
||||
uvStrategy: 'path-prerequisite',
|
||||
bootstrapUv: false,
|
||||
missingUvBehavior: 'focused-error',
|
||||
},
|
||||
requiredBeforePublishing: ['Choose public release version.'],
|
||||
});
|
||||
|
||||
for (const packageInfo of INTERNAL_NPM_WORKSPACE_PACKAGES) {
|
||||
await mkdir(join(root, packageInfo.packageRoot), { recursive: true });
|
||||
await writeJson(join(root, packageInfo.packageRoot, 'package.json'), {
|
||||
|
|
@ -64,7 +94,7 @@ async function writeUploadableArtifactFixtures(layout) {
|
|||
|
||||
describe('packageArtifactLayout', () => {
|
||||
it('uses stable artifact paths under ktx/dist/artifacts', () => {
|
||||
const layout = packageArtifactLayout('/repo/ktx');
|
||||
const layout = packageArtifactLayout('/repo/ktx', PUBLIC_NPM_PACKAGE_VERSION);
|
||||
|
||||
assert.equal(layout.artifactDir, '/repo/ktx/dist/artifacts');
|
||||
assert.equal(layout.npmDir, '/repo/ktx/dist/artifacts/npm');
|
||||
|
|
@ -76,7 +106,7 @@ describe('packageArtifactLayout', () => {
|
|||
|
||||
describe('buildArtifactCommands', () => {
|
||||
it('builds TypeScript packages in parallel topology, then the runtime wheel, then packs npm artifacts', () => {
|
||||
const layout = packageArtifactLayout('/repo/ktx');
|
||||
const layout = packageArtifactLayout('/repo/ktx', PUBLIC_NPM_PACKAGE_VERSION);
|
||||
const commands = buildArtifactCommands(layout);
|
||||
|
||||
assert.deepEqual(
|
||||
|
|
@ -147,7 +177,7 @@ describe('findPythonArtifacts', () => {
|
|||
describe('artifact manifest', () => {
|
||||
it('writes release metadata, source revision, checksums, and byte counts for every uploadable artifact', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'ktx-artifacts-manifest-test-'));
|
||||
const layout = packageArtifactLayout(root);
|
||||
const layout = packageArtifactLayout(root, PUBLIC_NPM_PACKAGE_VERSION);
|
||||
try {
|
||||
await writeReleaseMetadataInputs(root);
|
||||
await writeUploadableArtifactFixtures(layout);
|
||||
|
|
@ -244,7 +274,7 @@ describe('artifact manifest', () => {
|
|||
describe('verifyArtifactManifest', () => {
|
||||
it('accepts a schema version 2 manifest that matches the artifact directory', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'ktx-artifacts-verify-manifest-test-'));
|
||||
const layout = packageArtifactLayout(root);
|
||||
const layout = packageArtifactLayout(root, PUBLIC_NPM_PACKAGE_VERSION);
|
||||
try {
|
||||
await writeReleaseMetadataInputs(root);
|
||||
await writeUploadableArtifactFixtures(layout);
|
||||
|
|
@ -266,7 +296,7 @@ describe('verifyArtifactManifest', () => {
|
|||
|
||||
it('rejects a manifest when a file checksum has drifted', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'ktx-artifacts-checksum-drift-test-'));
|
||||
const layout = packageArtifactLayout(root);
|
||||
const layout = packageArtifactLayout(root, PUBLIC_NPM_PACKAGE_VERSION);
|
||||
try {
|
||||
await writeReleaseMetadataInputs(root);
|
||||
await writeUploadableArtifactFixtures(layout);
|
||||
|
|
@ -286,7 +316,7 @@ describe('verifyArtifactManifest', () => {
|
|||
|
||||
it('rejects a manifest with an unsafe artifact path', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'ktx-artifacts-path-test-'));
|
||||
const layout = packageArtifactLayout(root);
|
||||
const layout = packageArtifactLayout(root, PUBLIC_NPM_PACKAGE_VERSION);
|
||||
try {
|
||||
await writeReleaseMetadataInputs(root);
|
||||
await writeUploadableArtifactFixtures(layout);
|
||||
|
|
@ -304,7 +334,7 @@ describe('verifyArtifactManifest', () => {
|
|||
|
||||
it('rejects a manifest from the wrong source revision when one is required', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'ktx-artifacts-revision-test-'));
|
||||
const layout = packageArtifactLayout(root);
|
||||
const layout = packageArtifactLayout(root, PUBLIC_NPM_PACKAGE_VERSION);
|
||||
try {
|
||||
await writeReleaseMetadataInputs(root);
|
||||
await writeUploadableArtifactFixtures(layout);
|
||||
|
|
@ -328,7 +358,7 @@ describe('verifyArtifactManifest', () => {
|
|||
describe('copyRuntimeWheelAssets', () => {
|
||||
it('copies the runtime wheel and checksum manifest into CLI assets', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'ktx-runtime-assets-test-'));
|
||||
const layout = packageArtifactLayout(root);
|
||||
const layout = packageArtifactLayout(root, PUBLIC_NPM_PACKAGE_VERSION);
|
||||
try {
|
||||
await mkdir(layout.pythonDir, { recursive: true });
|
||||
await writeFile(
|
||||
|
|
@ -399,7 +429,7 @@ describe('standalone Python artifact cleanup', () => {
|
|||
|
||||
describe('verification snippets', () => {
|
||||
it('pins the smoke project to the public package artifact', () => {
|
||||
const layout = packageArtifactLayout('/repo/ktx');
|
||||
const layout = packageArtifactLayout('/repo/ktx', PUBLIC_NPM_PACKAGE_VERSION);
|
||||
|
||||
const packageJson = npmSmokePackageJson(layout);
|
||||
assert.deepEqual(packageJson.dependencies, {
|
||||
|
|
|
|||
53
scripts/public-npm-release-metadata.mjs
Normal file
53
scripts/public-npm-release-metadata.mjs
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export const PUBLIC_NPM_PACKAGE_NAME = '@kaelio/ktx';
|
||||
export const PUBLIC_NPM_RELEASE_TAGS = new Set(['latest', 'next']);
|
||||
|
||||
const SEMVER_PATTERN =
|
||||
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
||||
|
||||
function scriptRootDir() {
|
||||
return resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
}
|
||||
|
||||
export function releasePolicyPath(rootDir = scriptRootDir()) {
|
||||
return join(rootDir, 'release-policy.json');
|
||||
}
|
||||
|
||||
function readJsonSync(path) {
|
||||
return JSON.parse(readFileSync(path, 'utf8'));
|
||||
}
|
||||
|
||||
export function assertPublicNpmPackageVersion(version) {
|
||||
if (typeof version !== 'string' || !SEMVER_PATTERN.test(version)) {
|
||||
throw new Error(`Invalid public npm package version: ${version}`);
|
||||
}
|
||||
return version;
|
||||
}
|
||||
|
||||
export function assertPublicNpmReleaseTag(tag) {
|
||||
if (!PUBLIC_NPM_RELEASE_TAGS.has(tag)) {
|
||||
throw new Error(`Invalid public npm release tag: ${tag}`);
|
||||
}
|
||||
return tag;
|
||||
}
|
||||
|
||||
export function readPublicNpmReleaseMetadata(rootDir = scriptRootDir()) {
|
||||
const policy = readJsonSync(releasePolicyPath(rootDir));
|
||||
const version = assertPublicNpmPackageVersion(policy.publicNpmPackageVersion);
|
||||
const tag = assertPublicNpmReleaseTag(policy.npm?.tag);
|
||||
|
||||
return {
|
||||
packageName: PUBLIC_NPM_PACKAGE_NAME,
|
||||
version,
|
||||
tag,
|
||||
};
|
||||
}
|
||||
|
||||
export function publicNpmPackageVersion(rootDir = scriptRootDir()) {
|
||||
return readPublicNpmReleaseMetadata(rootDir).version;
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ import { dirname, join, resolve } from 'node:path';
|
|||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
import { packageArtifactLayout, packageReleaseMetadata, verifyArtifactManifest } from './package-artifacts.mjs';
|
||||
import { PUBLIC_NPM_PACKAGE_VERSION } from './build-public-npm-package.mjs';
|
||||
import { assertPublicNpmPackageVersion, publicNpmPackageVersion } from './public-npm-release-metadata.mjs';
|
||||
import { readPublishedPackageSmokeConfig } from './published-package-smoke-config.mjs';
|
||||
|
||||
function scriptRootDir() {
|
||||
|
|
@ -138,6 +138,8 @@ export function validateReleasePolicy(policy) {
|
|||
throw new Error(`Unsupported release policy schemaVersion: ${policy.schemaVersion}`);
|
||||
}
|
||||
assertSupportedReleaseMode(policy.releaseMode);
|
||||
assertString(policy.publicNpmPackageVersion, 'Release policy publicNpmPackageVersion');
|
||||
assertPublicNpmPackageVersion(policy.publicNpmPackageVersion);
|
||||
assertPlainObject(policy.npm, 'Release policy npm');
|
||||
assertPlainObject(policy.python, 'Release policy python');
|
||||
assertPlainObject(policy.publishedPackageSmoke, 'Release policy publishedPackageSmoke');
|
||||
|
|
@ -202,7 +204,7 @@ function publishedPackageSmokeGate(policy) {
|
|||
};
|
||||
}
|
||||
|
||||
function assertNonPublishingArtifactPolicy(policy, metadata) {
|
||||
function assertNonPublishingArtifactPolicy(policy, metadata, publicPackageVersion) {
|
||||
const policyLabel =
|
||||
policy.releaseMode === CI_ARTIFACT_ONLY_RELEASE_MODE ? 'ci-artifact-only policy' : `${policy.releaseMode} policy`;
|
||||
|
||||
|
|
@ -232,8 +234,8 @@ function assertNonPublishingArtifactPolicy(policy, metadata) {
|
|||
if (entry.private !== false) {
|
||||
throw new Error(`${policyLabel} npm package @kaelio/ktx must be publishable when npm.publish is false`);
|
||||
}
|
||||
if (entry.packageVersion !== PUBLIC_NPM_PACKAGE_VERSION) {
|
||||
throw new Error(`${policyLabel} npm package @kaelio/ktx must use public version ${PUBLIC_NPM_PACKAGE_VERSION}`);
|
||||
if (entry.packageVersion !== publicPackageVersion) {
|
||||
throw new Error(`${policyLabel} npm package @kaelio/ktx must use public version ${publicPackageVersion}`);
|
||||
}
|
||||
} else if (entry.private !== true) {
|
||||
throw new Error(`${policyLabel} npm package ${entry.packageName} must remain private`);
|
||||
|
|
@ -244,7 +246,7 @@ function assertNonPublishingArtifactPolicy(policy, metadata) {
|
|||
}
|
||||
}
|
||||
|
||||
function assertNpmPublicReleaseReadyPolicy(policy, metadata) {
|
||||
function assertNpmPublicReleaseReadyPolicy(policy, metadata, publicPackageVersion) {
|
||||
if (policy.npm.publish !== true) {
|
||||
throw new Error('npm-public-release-ready policy requires npm.publish true');
|
||||
}
|
||||
|
|
@ -265,29 +267,30 @@ function assertNpmPublicReleaseReadyPolicy(policy, metadata) {
|
|||
if (npmMetadata.private !== false) {
|
||||
throw new Error('npm-public-release-ready policy requires @kaelio/ktx to be publishable');
|
||||
}
|
||||
if (npmMetadata.packageVersion !== PUBLIC_NPM_PACKAGE_VERSION) {
|
||||
if (npmMetadata.packageVersion !== publicPackageVersion) {
|
||||
throw new Error(
|
||||
`npm-public-release-ready policy expected @kaelio/ktx ${PUBLIC_NPM_PACKAGE_VERSION}, got ${npmMetadata.packageVersion}`,
|
||||
`npm-public-release-ready policy expected @kaelio/ktx ${publicPackageVersion}, got ${npmMetadata.packageVersion}`,
|
||||
);
|
||||
}
|
||||
if (policy.publishedPackageSmoke.packageName !== '@kaelio/ktx') {
|
||||
throw new Error('npm-public-release-ready policy requires publishedPackageSmoke.packageName @kaelio/ktx');
|
||||
}
|
||||
if (policy.publishedPackageSmoke.version !== PUBLIC_NPM_PACKAGE_VERSION) {
|
||||
throw new Error(`npm-public-release-ready policy requires publishedPackageSmoke.version ${PUBLIC_NPM_PACKAGE_VERSION}`);
|
||||
if (policy.publishedPackageSmoke.version !== publicPackageVersion) {
|
||||
throw new Error(`npm-public-release-ready policy requires publishedPackageSmoke.version ${publicPackageVersion}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function releaseReadinessReport(rootDir = scriptRootDir()) {
|
||||
const policy = validateReleasePolicy(await readReleasePolicy(rootDir));
|
||||
const layout = packageArtifactLayout(rootDir);
|
||||
const publicPackageVersion = publicNpmPackageVersion(rootDir);
|
||||
const layout = packageArtifactLayout(rootDir, publicPackageVersion);
|
||||
const manifest = await verifyArtifactManifest(layout);
|
||||
const metadata = await packageReleaseMetadata(rootDir);
|
||||
const metadata = await packageReleaseMetadata(rootDir, publicPackageVersion);
|
||||
|
||||
if (policy.releaseMode === NPM_PUBLIC_RELEASE_READY_MODE) {
|
||||
assertNpmPublicReleaseReadyPolicy(policy, metadata);
|
||||
assertNpmPublicReleaseReadyPolicy(policy, metadata, publicPackageVersion);
|
||||
} else {
|
||||
assertNonPublishingArtifactPolicy(policy, metadata);
|
||||
assertNonPublishingArtifactPolicy(policy, metadata, publicPackageVersion);
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -303,7 +306,7 @@ export async function releaseReadinessReport(rootDir = scriptRootDir()) {
|
|||
policy.releaseMode === NPM_PUBLIC_RELEASE_READY_MODE
|
||||
? {
|
||||
packageName: '@kaelio/ktx',
|
||||
version: PUBLIC_NPM_PACKAGE_VERSION,
|
||||
version: publicPackageVersion,
|
||||
access: policy.npm.access,
|
||||
tag: policy.npm.tag,
|
||||
registry: policy.npm.registry,
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ function releasePolicy(overrides = {}) {
|
|||
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
publicNpmPackageVersion: PUBLIC_NPM_PACKAGE_VERSION,
|
||||
releaseMode: 'ci-artifact-only',
|
||||
npm: {
|
||||
publish: false,
|
||||
|
|
|
|||
|
|
@ -3,17 +3,23 @@ import { readFile } from 'node:fs/promises';
|
|||
import { describe, it } from 'node:test';
|
||||
|
||||
describe('release workflow', () => {
|
||||
it('publishes only from manual dispatch with an explicit live input', async () => {
|
||||
it('runs semantic-release only from manual dispatch with explicit release inputs', async () => {
|
||||
const workflow = await readFile(new URL('../.github/workflows/release.yml', import.meta.url), 'utf8');
|
||||
|
||||
assert.match(workflow, /^name: KTX Release$/m);
|
||||
assert.match(workflow, /^ workflow_dispatch:$/m);
|
||||
assert.match(workflow, /release_kind:/);
|
||||
assert.match(workflow, /options:\n - rc\n - stable/);
|
||||
assert.match(workflow, /force_release:/);
|
||||
assert.match(workflow, /publish_live:/);
|
||||
assert.match(workflow, /default: false/);
|
||||
assert.match(workflow, /pnpm run artifacts:check/);
|
||||
assert.match(workflow, /pnpm run release:readiness/);
|
||||
assert.match(workflow, /pnpm run release:npm-publish$/m);
|
||||
assert.match(workflow, /pnpm run release:npm-publish -- --publish/);
|
||||
assert.match(workflow, /^ contents: write$/m);
|
||||
assert.match(workflow, /fetch-depth: 0/);
|
||||
assert.match(workflow, /registry-url: "https:\/\/registry\.npmjs\.org"/);
|
||||
assert.match(workflow, /pnpm run semantic-release:dry-run/);
|
||||
assert.match(workflow, /pnpm run semantic-release$/m);
|
||||
assert.match(workflow, /KTX_RELEASE_KIND: \$\{\{ inputs.release_kind \}\}/);
|
||||
assert.match(workflow, /FORCE_RELEASE: \$\{\{ inputs.force_release \}\}/);
|
||||
assert.match(workflow, /NODE_AUTH_TOKEN: \$\{\{ secrets.NPM_TOKEN \}\}/);
|
||||
assert.doesNotMatch(workflow, /^ push:/m);
|
||||
assert.doesNotMatch(workflow, /^ pull_request:/m);
|
||||
|
|
|
|||
176
scripts/semantic-release-config.cjs
Normal file
176
scripts/semantic-release-config.cjs
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
const releaseRules = [
|
||||
{ breaking: true, release: 'minor' },
|
||||
{ revert: true, release: 'patch' },
|
||||
{ type: 'feat', release: 'minor' },
|
||||
{ type: 'feature', release: 'minor' },
|
||||
{ type: 'enhancement', release: 'minor' },
|
||||
{ type: 'fix', release: 'patch' },
|
||||
{ type: 'bug', release: 'patch' },
|
||||
{ type: 'bugfix', release: 'patch' },
|
||||
{ type: 'patch', release: 'patch' },
|
||||
{ type: 'perf', release: 'patch' },
|
||||
{ type: 'performance', release: 'patch' },
|
||||
{ type: 'optimization', release: 'patch' },
|
||||
{ type: 'security', release: 'patch' },
|
||||
{ type: 'vulnerability', release: 'patch' },
|
||||
{ type: 'deps', release: 'patch' },
|
||||
{ type: 'dependencies', release: 'patch' },
|
||||
{ type: 'upgrade', release: 'patch' },
|
||||
{ type: 'update', release: 'patch' },
|
||||
{ type: 'style', release: 'patch' },
|
||||
{ type: 'refactor', release: 'patch' },
|
||||
{ type: 'refactoring', release: 'patch' },
|
||||
{ type: 'cleanup', release: 'patch' },
|
||||
{ type: 'test', release: 'patch' },
|
||||
{ type: 'tests', release: 'patch' },
|
||||
{ type: 'testing', release: 'patch' },
|
||||
{ type: 'build', release: 'patch' },
|
||||
{ type: 'ci', release: 'patch' },
|
||||
{ type: 'cd', release: 'patch' },
|
||||
{ type: 'config', release: 'patch' },
|
||||
{ type: 'workflow', release: 'patch' },
|
||||
{ type: 'pipeline', release: 'patch' },
|
||||
{ type: 'chore', release: 'patch' },
|
||||
{ type: 'docs', release: 'patch' },
|
||||
{ type: 'documentation', release: 'patch' },
|
||||
{ type: 'breaking', release: 'minor' },
|
||||
{ type: 'breaking-change', release: 'minor' },
|
||||
{ type: 'major', release: 'minor' },
|
||||
];
|
||||
|
||||
const releaseNoteTypes = [
|
||||
{ type: 'feat', section: 'Features', hidden: false },
|
||||
{ type: 'feature', section: 'Features', hidden: false },
|
||||
{ type: 'fix', section: 'Bug Fixes', hidden: false },
|
||||
{ type: 'bug', section: 'Bug Fixes', hidden: false },
|
||||
{ type: 'bugfix', section: 'Bug Fixes', hidden: false },
|
||||
{ type: 'perf', section: 'Performance Improvements', hidden: false },
|
||||
{ type: 'performance', section: 'Performance Improvements', hidden: false },
|
||||
{ type: 'optimization', section: 'Performance Improvements', hidden: false },
|
||||
{ type: 'security', section: 'Security', hidden: false },
|
||||
{ type: 'vulnerability', section: 'Security', hidden: false },
|
||||
{ type: 'deps', section: 'Dependencies', hidden: false },
|
||||
{ type: 'dependencies', section: 'Dependencies', hidden: false },
|
||||
{ type: 'upgrade', section: 'Dependencies', hidden: false },
|
||||
{ type: 'update', section: 'Dependencies', hidden: false },
|
||||
{ type: 'docs', section: 'Documentation', hidden: false },
|
||||
{ type: 'documentation', section: 'Documentation', hidden: false },
|
||||
{ type: 'style', section: 'Styling', hidden: false },
|
||||
{ type: 'refactor', section: 'Code Refactoring', hidden: false },
|
||||
{ type: 'refactoring', section: 'Code Refactoring', hidden: false },
|
||||
{ type: 'cleanup', section: 'Code Refactoring', hidden: false },
|
||||
{ type: 'test', section: 'Tests', hidden: false },
|
||||
{ type: 'tests', section: 'Tests', hidden: false },
|
||||
{ type: 'testing', section: 'Tests', hidden: false },
|
||||
{ type: 'build', section: 'Build System', hidden: false },
|
||||
{ type: 'ci', section: 'Continuous Integration', hidden: false },
|
||||
{ type: 'cd', section: 'Continuous Integration', hidden: false },
|
||||
{ type: 'config', section: 'Configuration', hidden: false },
|
||||
{ type: 'workflow', section: 'Continuous Integration', hidden: false },
|
||||
{ type: 'pipeline', section: 'Continuous Integration', hidden: false },
|
||||
{ type: 'chore', section: 'Other Changes', hidden: false },
|
||||
{ type: 'breaking', section: 'BREAKING CHANGES', hidden: false },
|
||||
{ type: 'breaking-change', section: 'BREAKING CHANGES', hidden: false },
|
||||
{ type: 'major', section: 'BREAKING CHANGES', hidden: false },
|
||||
];
|
||||
|
||||
function currentBranch(env) {
|
||||
return env.GITHUB_REF_NAME || env.INPUT_BRANCH || 'main';
|
||||
}
|
||||
|
||||
function releaseKind(env) {
|
||||
return env.KTX_RELEASE_KIND || env.INPUT_RELEASE_KIND || 'rc';
|
||||
}
|
||||
|
||||
function releaseTag(kind) {
|
||||
return kind === 'rc' ? 'next' : 'latest';
|
||||
}
|
||||
|
||||
function releaseBranches(env = process.env) {
|
||||
const branch = currentBranch(env);
|
||||
const kind = releaseKind(env);
|
||||
|
||||
if (kind === 'rc') {
|
||||
return [{ name: branch, prerelease: 'rc', channel: 'next' }];
|
||||
}
|
||||
|
||||
if (kind === 'stable') {
|
||||
if (branch !== 'main') {
|
||||
throw new Error(`Stable KTX releases must run from main, got ${branch}`);
|
||||
}
|
||||
return ['main'];
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported KTX_RELEASE_KIND: ${kind}`);
|
||||
}
|
||||
|
||||
function createReleaseConfig(env = process.env) {
|
||||
const kind = releaseKind(env);
|
||||
const tag = releaseTag(kind);
|
||||
|
||||
return {
|
||||
tagFormat: 'v${version}',
|
||||
branches: releaseBranches(env),
|
||||
plugins: [
|
||||
[
|
||||
'@semantic-release/commit-analyzer',
|
||||
{
|
||||
releaseRules,
|
||||
},
|
||||
],
|
||||
[
|
||||
'@semantic-release/exec',
|
||||
{
|
||||
analyzeCommitsCmd: 'node -e "console.log(process.env.FORCE_RELEASE === \'true\' ? \'patch\' : \'\')"',
|
||||
},
|
||||
],
|
||||
[
|
||||
'@semantic-release/release-notes-generator',
|
||||
{
|
||||
preset: 'conventionalcommits',
|
||||
presetConfig: {
|
||||
types: releaseNoteTypes,
|
||||
},
|
||||
},
|
||||
],
|
||||
'@semantic-release/changelog',
|
||||
[
|
||||
'@semantic-release/exec',
|
||||
{
|
||||
prepareCmd: [
|
||||
`node scripts/update-public-release-version.mjs "\${nextRelease.version}" "${tag}"`,
|
||||
'pnpm run artifacts:check',
|
||||
'pnpm run release:readiness',
|
||||
].join(' && '),
|
||||
publishCmd: [
|
||||
'pnpm run release:npm-publish -- --publish',
|
||||
'pnpm run release:published-smoke',
|
||||
].join(' && '),
|
||||
},
|
||||
],
|
||||
[
|
||||
'@semantic-release/git',
|
||||
{
|
||||
assets: ['CHANGELOG.md', 'package.json', 'release-policy.json'],
|
||||
message: 'chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}',
|
||||
},
|
||||
],
|
||||
[
|
||||
'@semantic-release/github',
|
||||
{
|
||||
successComment: false,
|
||||
failComment: false,
|
||||
failTitle: false,
|
||||
releasedLabels: false,
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createReleaseConfig,
|
||||
releaseBranches,
|
||||
releaseKind,
|
||||
releaseTag,
|
||||
};
|
||||
53
scripts/semantic-release-config.test.mjs
Normal file
53
scripts/semantic-release-config.test.mjs
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { createReleaseConfig, releaseBranches, releaseKind, releaseTag } = require('./semantic-release-config.cjs');
|
||||
|
||||
function releaseExecOptions(config) {
|
||||
return config.plugins.find((plugin) => Array.isArray(plugin) && plugin[0] === '@semantic-release/exec' && plugin[1].prepareCmd)[1];
|
||||
}
|
||||
|
||||
describe('semantic-release config', () => {
|
||||
it('configures manual rc releases on the selected branch with next channel', () => {
|
||||
assert.equal(releaseKind({ KTX_RELEASE_KIND: 'rc' }), 'rc');
|
||||
assert.equal(releaseTag('rc'), 'next');
|
||||
assert.deepEqual(releaseBranches({ KTX_RELEASE_KIND: 'rc', GITHUB_REF_NAME: 'release-candidate' }), [
|
||||
{ name: 'release-candidate', prerelease: 'rc', channel: 'next' },
|
||||
]);
|
||||
|
||||
const config = createReleaseConfig({ KTX_RELEASE_KIND: 'rc', GITHUB_REF_NAME: 'release-candidate' });
|
||||
assert.match(
|
||||
releaseExecOptions(config).prepareCmd,
|
||||
/update-public-release-version\.mjs "\$\{nextRelease\.version\}" "next"/,
|
||||
);
|
||||
});
|
||||
|
||||
it('configures stable releases only from main with latest tag', () => {
|
||||
assert.equal(releaseKind({ KTX_RELEASE_KIND: 'stable' }), 'stable');
|
||||
assert.equal(releaseTag('stable'), 'latest');
|
||||
assert.deepEqual(releaseBranches({ KTX_RELEASE_KIND: 'stable', GITHUB_REF_NAME: 'main' }), ['main']);
|
||||
|
||||
const config = createReleaseConfig({ KTX_RELEASE_KIND: 'stable', GITHUB_REF_NAME: 'main' });
|
||||
assert.match(
|
||||
releaseExecOptions(config).prepareCmd,
|
||||
/update-public-release-version\.mjs "\$\{nextRelease\.version\}" "latest"/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects stable releases from non-main branches', () => {
|
||||
assert.throws(
|
||||
() => releaseBranches({ KTX_RELEASE_KIND: 'stable', GITHUB_REF_NAME: 'feature/release-test' }),
|
||||
/Stable KTX releases must run from main, got feature\/release-test/,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the force-release patch escape hatch', () => {
|
||||
const config = createReleaseConfig({ KTX_RELEASE_KIND: 'rc', GITHUB_REF_NAME: 'main' });
|
||||
const analyzeExec = config.plugins.find(
|
||||
(plugin) => Array.isArray(plugin) && plugin[0] === '@semantic-release/exec' && plugin[1].analyzeCommitsCmd,
|
||||
);
|
||||
assert.match(analyzeExec[1].analyzeCommitsCmd, /FORCE_RELEASE === 'true' \? 'patch' : ''/);
|
||||
});
|
||||
});
|
||||
78
scripts/update-public-release-version.mjs
Normal file
78
scripts/update-public-release-version.mjs
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
import {
|
||||
PUBLIC_NPM_PACKAGE_NAME,
|
||||
assertPublicNpmPackageVersion,
|
||||
assertPublicNpmReleaseTag,
|
||||
releasePolicyPath,
|
||||
} from './public-npm-release-metadata.mjs';
|
||||
|
||||
function scriptRootDir() {
|
||||
return resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
}
|
||||
|
||||
async function readJson(path) {
|
||||
return JSON.parse(await readFile(path, 'utf8'));
|
||||
}
|
||||
|
||||
async function writeJson(path, value) {
|
||||
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
export async function updatePublicReleaseVersion(rootDir, version, tag) {
|
||||
const safeVersion = assertPublicNpmPackageVersion(version);
|
||||
const safeTag = assertPublicNpmReleaseTag(tag);
|
||||
|
||||
const packageJsonPath = join(rootDir, 'package.json');
|
||||
const packageJson = await readJson(packageJsonPath);
|
||||
packageJson.version = safeVersion;
|
||||
await writeJson(packageJsonPath, packageJson);
|
||||
|
||||
const policyPath = releasePolicyPath(rootDir);
|
||||
const policy = await readJson(policyPath);
|
||||
policy.publicNpmPackageVersion = safeVersion;
|
||||
policy.releaseMode = 'npm-public-release-ready';
|
||||
policy.requiredBeforePublishing = [];
|
||||
policy.npm = {
|
||||
...policy.npm,
|
||||
publish: true,
|
||||
registry: policy.npm?.registry ?? null,
|
||||
access: 'public',
|
||||
tag: safeTag,
|
||||
packages: [PUBLIC_NPM_PACKAGE_NAME],
|
||||
};
|
||||
policy.publishedPackageSmoke = {
|
||||
...policy.publishedPackageSmoke,
|
||||
packageName: PUBLIC_NPM_PACKAGE_NAME,
|
||||
version: safeVersion,
|
||||
};
|
||||
await writeJson(policyPath, policy);
|
||||
|
||||
return {
|
||||
version: safeVersion,
|
||||
tag: safeTag,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [version, tag] = process.argv.slice(2);
|
||||
if (!version || !tag) {
|
||||
throw new Error('Usage: node scripts/update-public-release-version.mjs <version> <latest|next>');
|
||||
}
|
||||
|
||||
const result = await updatePublicReleaseVersion(scriptRootDir(), version, tag);
|
||||
process.stdout.write(`Updated ${PUBLIC_NPM_PACKAGE_NAME} release metadata to ${result.version} (${result.tag})\n`);
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
|
||||
try {
|
||||
await main();
|
||||
} catch (error) {
|
||||
process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
107
scripts/update-public-release-version.test.mjs
Normal file
107
scripts/update-public-release-version.test.mjs
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
import { updatePublicReleaseVersion } from './update-public-release-version.mjs';
|
||||
|
||||
async function writeJson(path, value) {
|
||||
await mkdir(join(path, '..'), { recursive: true });
|
||||
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
async function readJson(path) {
|
||||
return JSON.parse(await readFile(path, 'utf8'));
|
||||
}
|
||||
|
||||
async function writeReleaseFixture(root) {
|
||||
await writeJson(join(root, 'package.json'), {
|
||||
name: 'ktx-workspace',
|
||||
version: '0.0.0-private',
|
||||
private: true,
|
||||
});
|
||||
await writeJson(join(root, 'release-policy.json'), {
|
||||
schemaVersion: 1,
|
||||
publicNpmPackageVersion: '0.1.0-rc.1',
|
||||
releaseMode: 'ci-artifact-only',
|
||||
npm: {
|
||||
publish: false,
|
||||
registry: null,
|
||||
access: 'public',
|
||||
tag: 'next',
|
||||
packages: ['@kaelio/ktx'],
|
||||
},
|
||||
python: {
|
||||
publish: false,
|
||||
repository: null,
|
||||
packages: ['kaelio-ktx'],
|
||||
},
|
||||
publishedPackageSmoke: {
|
||||
packageName: '@kaelio/ktx',
|
||||
version: '0.1.0-rc.1',
|
||||
registry: null,
|
||||
},
|
||||
runtimeInstaller: {
|
||||
uvStrategy: 'path-prerequisite',
|
||||
bootstrapUv: false,
|
||||
missingUvBehavior: 'focused-error',
|
||||
},
|
||||
requiredBeforePublishing: ['Choose public release version.'],
|
||||
});
|
||||
}
|
||||
|
||||
describe('updatePublicReleaseVersion', () => {
|
||||
it('updates package and release policy metadata for rc releases', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'ktx-release-version-test-'));
|
||||
try {
|
||||
await writeReleaseFixture(root);
|
||||
|
||||
await updatePublicReleaseVersion(root, '0.1.0-rc.2', 'next');
|
||||
|
||||
assert.equal((await readJson(join(root, 'package.json'))).version, '0.1.0-rc.2');
|
||||
assert.deepEqual(await readJson(join(root, 'release-policy.json')), {
|
||||
schemaVersion: 1,
|
||||
publicNpmPackageVersion: '0.1.0-rc.2',
|
||||
releaseMode: 'npm-public-release-ready',
|
||||
npm: {
|
||||
publish: true,
|
||||
registry: null,
|
||||
access: 'public',
|
||||
tag: 'next',
|
||||
packages: ['@kaelio/ktx'],
|
||||
},
|
||||
python: {
|
||||
publish: false,
|
||||
repository: null,
|
||||
packages: ['kaelio-ktx'],
|
||||
},
|
||||
publishedPackageSmoke: {
|
||||
packageName: '@kaelio/ktx',
|
||||
version: '0.1.0-rc.2',
|
||||
registry: null,
|
||||
},
|
||||
runtimeInstaller: {
|
||||
uvStrategy: 'path-prerequisite',
|
||||
bootstrapUv: false,
|
||||
missingUvBehavior: 'focused-error',
|
||||
},
|
||||
requiredBeforePublishing: [],
|
||||
});
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects invalid versions and tags', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'ktx-release-version-invalid-test-'));
|
||||
try {
|
||||
await writeReleaseFixture(root);
|
||||
|
||||
await assert.rejects(() => updatePublicReleaseVersion(root, 'not a version', 'next'), /Invalid public npm package version/);
|
||||
await assert.rejects(() => updatePublicReleaseVersion(root, '0.2.0', 'canary'), /Invalid public npm release tag/);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue