mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-07 07:55:13 +02:00
ci: simplify ktx release flow
This commit is contained in:
parent
7110aa6f5c
commit
bbd9568287
14 changed files with 98 additions and 510 deletions
40
.github/workflows/release.yml
vendored
40
.github/workflows/release.yml
vendored
|
|
@ -7,10 +7,10 @@ on:
|
|||
description: "Release kind: rc publishes to next, stable publishes to latest"
|
||||
required: true
|
||||
type: choice
|
||||
default: "rc"
|
||||
default: "stable"
|
||||
options:
|
||||
- rc
|
||||
- stable
|
||||
- rc
|
||||
force_release:
|
||||
description: "Force a patch release even if semantic-release finds no releasable commits"
|
||||
required: false
|
||||
|
|
@ -20,7 +20,7 @@ on:
|
|||
description: "Create the release and publish @kaelio/ktx to npm instead of running a dry-run"
|
||||
required: true
|
||||
type: boolean
|
||||
default: false
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
|
@ -69,20 +69,6 @@ jobs:
|
|||
- name: Install Python dependencies
|
||||
run: uv sync --all-packages
|
||||
|
||||
- name: Prepare first stable release floor
|
||||
if: ${{ inputs.release_kind == 'stable' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
stable_tags="$(git tag --merged HEAD --list 'v[0-9]*.[0-9]*.[0-9]*' | grep -v '-' || true)"
|
||||
if [ -n "${stable_tags}" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
root_commit="$(git rev-list --max-parents=0 HEAD | tail -n 1)"
|
||||
git tag v0.0.0 "${root_commit}"
|
||||
echo "KTX_STABLE_RELEASE_FLOOR_TAG=v0.0.0" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Prepare next prerelease branch
|
||||
if: ${{ inputs.release_kind == 'rc' }}
|
||||
run: |
|
||||
|
|
@ -104,6 +90,26 @@ jobs:
|
|||
env:
|
||||
KTX_PRERELEASE_BRANCH: next
|
||||
|
||||
- name: Prepare npm package root for release verification
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
mkdir -p dist/public-npm-package
|
||||
node --input-type=module <<'EOF'
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
|
||||
const packageJson = {
|
||||
name: '@kaelio/ktx',
|
||||
version: '0.0.0',
|
||||
private: false
|
||||
};
|
||||
|
||||
await writeFile(
|
||||
'dist/public-npm-package/package.json',
|
||||
`${JSON.stringify(packageJson, null, 2)}\n`
|
||||
);
|
||||
EOF
|
||||
|
||||
- name: Dry-run semantic release
|
||||
if: ${{ !inputs.publish_live }}
|
||||
run: |
|
||||
|
|
|
|||
|
|
@ -15,7 +15,9 @@ KTX has two npm release channels:
|
|||
Run rc releases from the source branch you want to publish. The workflow
|
||||
creates or updates the `next` prerelease branch from that source branch before
|
||||
running semantic-release, because semantic-release requires a dedicated
|
||||
prerelease branch in addition to the stable `main` branch.
|
||||
prerelease branch in addition to the stable `main` branch. You can publish an
|
||||
rc from `main` when you want to validate the current stable branch before a
|
||||
stable release.
|
||||
|
||||
Run stable releases only from `main`. The workflow rejects stable releases from
|
||||
other branches.
|
||||
|
|
@ -31,16 +33,24 @@ Before you publish, confirm these requirements:
|
|||
GitHub Actions run through OpenID Connect.
|
||||
- The repository has release metadata in `release-policy.json` for the current
|
||||
public package line, such as `0.1.0-rc.1` or `0.1.0`.
|
||||
- The repository has a stable baseline tag when you need semantic-release to
|
||||
publish the first stable version as `0.1.0`.
|
||||
|
||||
If no stable baseline tag exists, semantic-release treats the stable run as the
|
||||
first release. KTX seeds that first stable release from the base version in
|
||||
`release-policy.json`, so `0.1.0-rc.6` promotes to `0.1.0` instead of
|
||||
semantic-release's default `1.0.0`.
|
||||
semantic-release doesn't support choosing an arbitrary first `0.x` stable
|
||||
release. If KTX has no stable tag yet and you need the first stable release to
|
||||
be `0.1.0`, create and push the baseline tag once before running the live
|
||||
stable workflow:
|
||||
|
||||
KTX blocks automatic major releases. A major version requires an intentional
|
||||
manual release path that updates release metadata and creates the intended
|
||||
version explicitly; don't rely on semantic-release commit analysis for major
|
||||
bumps.
|
||||
```bash
|
||||
root_commit="$(git rev-list --max-parents=0 HEAD | tail -n 1)"
|
||||
git tag v0.0.0 "${root_commit}"
|
||||
git push origin v0.0.0
|
||||
```
|
||||
|
||||
KTX follows the same versioning schema as the main Kaelio release workflow:
|
||||
breaking-change and `major` commit markers create a minor release, not an
|
||||
automatic major release. A major version requires an intentional manual release
|
||||
path.
|
||||
|
||||
## Dry-run a release
|
||||
|
||||
|
|
@ -51,7 +61,7 @@ publishing to npm.
|
|||
2. Select **KTX Release**.
|
||||
3. Select the branch to release from.
|
||||
4. Set **release_kind** to `rc` or `stable`.
|
||||
5. Leave **publish_live** set to `false`.
|
||||
5. Set **publish_live** to `false`.
|
||||
6. Optional: Set **force_release** to `true` when you need a patch release even
|
||||
if semantic-release doesn't find a releasable commit.
|
||||
7. Run the workflow.
|
||||
|
|
@ -67,9 +77,9 @@ promoting to `latest`.
|
|||
|
||||
1. Open **Actions** in GitHub.
|
||||
2. Select **KTX Release**.
|
||||
3. Select the source branch to release from.
|
||||
3. Select the source branch to release from, including `main` when needed.
|
||||
4. Set **release_kind** to `rc`.
|
||||
5. Set **publish_live** to `true`.
|
||||
5. Leave **publish_live** set to `true`.
|
||||
6. Optional: Set **force_release** to `true`.
|
||||
7. Run the workflow.
|
||||
|
||||
|
|
@ -85,8 +95,8 @@ Publish a stable release from `main` after you have validated an rc package.
|
|||
1. Open **Actions** in GitHub.
|
||||
2. Select **KTX Release**.
|
||||
3. Select `main`.
|
||||
4. Set **release_kind** to `stable`.
|
||||
5. Set **publish_live** to `true`.
|
||||
4. Leave **release_kind** set to `stable`.
|
||||
5. Leave **publish_live** set to `true`.
|
||||
6. Optional: Set **force_release** to `true`.
|
||||
7. Run the workflow.
|
||||
|
||||
|
|
@ -97,7 +107,8 @@ release metadata.
|
|||
## Release metadata
|
||||
|
||||
semantic-release calls `scripts/update-public-release-version.mjs` during the
|
||||
prepare step. That script updates:
|
||||
prepare step before `@semantic-release/npm` publishes the package. That script
|
||||
updates:
|
||||
|
||||
- `package.json` with the semantic-release version.
|
||||
- `release-policy.json` with `publicNpmPackageVersion`, npm publish settings,
|
||||
|
|
@ -105,7 +116,9 @@ prepare step. That script updates:
|
|||
|
||||
The artifact packaging and readiness scripts read `publicNpmPackageVersion`
|
||||
from `release-policy.json`, so manual version edits in build scripts aren't
|
||||
needed for rc releases.
|
||||
needed for rc releases. The semantic-release npm plugin publishes the generated
|
||||
`dist/public-npm-package` tree and writes the release tarball under
|
||||
`dist/artifacts/npm`.
|
||||
|
||||
The bundled Python runtime wheel also derives its version from
|
||||
`publicNpmPackageVersion`. Stable npm versions are reused as-is, and rc
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
"ignoreDependencies": [
|
||||
"@semantic-release/commit-analyzer",
|
||||
"@semantic-release/github",
|
||||
"@semantic-release/npm",
|
||||
"@semantic-release/release-notes-generator",
|
||||
"conventional-changelog-conventionalcommits"
|
||||
]
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@
|
|||
"setup:dev": "node scripts/setup-dev.mjs",
|
||||
"release:published-smoke": "node scripts/published-package-smoke.mjs --require-config",
|
||||
"release:local-embeddings-smoke": "node scripts/local-embeddings-runtime-smoke.mjs --require-opt-in",
|
||||
"release:npm-publish": "node scripts/publish-public-npm-package.mjs",
|
||||
"release:readiness": "node scripts/release-readiness.mjs",
|
||||
"release:update-version": "node scripts/update-public-release-version.mjs",
|
||||
"relationships:acquire-public-fixtures": "node scripts/acquire-public-benchmark-fixtures.mjs",
|
||||
|
|
@ -54,6 +53,7 @@
|
|||
"@semantic-release/exec": "^7.1.0",
|
||||
"@semantic-release/git": "^10.0.1",
|
||||
"@semantic-release/github": "^12.0.8",
|
||||
"@semantic-release/npm": "^13.1.5",
|
||||
"@semantic-release/release-notes-generator": "^14.1.1",
|
||||
"@types/node": "^25.7.0",
|
||||
"better-sqlite3": "^12.10.0",
|
||||
|
|
|
|||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
|
|
@ -30,6 +30,9 @@ importers:
|
|||
'@semantic-release/github':
|
||||
specifier: ^12.0.8
|
||||
version: 12.0.8(semantic-release@25.0.3(typescript@6.0.3))
|
||||
'@semantic-release/npm':
|
||||
specifier: ^13.1.5
|
||||
version: 13.1.5(semantic-release@25.0.3(typescript@6.0.3))
|
||||
'@semantic-release/release-notes-generator':
|
||||
specifier: ^14.1.1
|
||||
version: 14.1.1(semantic-release@25.0.3(typescript@6.0.3))
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ const identifierSkipPrefixes = ['docs/', 'docs-site/', 'examples/', 'python/ktx-
|
|||
const identifierAllowPatterns = [
|
||||
/^packages\/cli\/src\/(?:index|managed-local-embeddings|managed-python-command|managed-python-daemon|managed-python-runtime|release-version|runtime)(?:\.test)?\.ts$/,
|
||||
/^python\/ktx-daemon\/src\/ktx_daemon\/__init__\.py$/,
|
||||
/^scripts\/(?:build-public-npm-package|build-python-runtime-wheel|local-embeddings-runtime-smoke|package-artifacts|public-npm-release-metadata|publish-public-npm-package|published-package-smoke|release-readiness)(?:\.test)?\.mjs$/,
|
||||
/^scripts\/(?:build-public-npm-package|build-python-runtime-wheel|local-embeddings-runtime-smoke|package-artifacts|public-npm-release-metadata|published-package-smoke|release-readiness)(?:\.test)?\.mjs$/,
|
||||
];
|
||||
const forbiddenIdentifierTerms = ['kae' + 'lio', 'Kae' + 'lio', 'KAE' + 'LIO_'];
|
||||
|
||||
|
|
|
|||
|
|
@ -79,7 +79,6 @@ describe('scanFileContent', () => {
|
|||
assert.equal(scanFileContent('scripts/local-embeddings-runtime-smoke.mjs', `@${name}/ktx`).length, 0);
|
||||
assert.equal(scanFileContent('scripts/package-artifacts.test.mjs', `${name}-ktx`).length, 0);
|
||||
assert.equal(scanFileContent('scripts/public-npm-release-metadata.mjs', `@${name}/ktx`).length, 0);
|
||||
assert.equal(scanFileContent('scripts/publish-public-npm-package.test.mjs', `@${name}/ktx`).length, 0);
|
||||
assert.equal(scanFileContent('packages/cli/src/release-version.ts', `@${name}/ktx`).length, 0);
|
||||
assert.equal(scanFileContent('packages/cli/src/managed-python-runtime.ts', `${name}_ktx`).length, 0);
|
||||
assert.equal(scanFileContent('python/ktx-daemon/src/ktx_daemon/__init__.py', `${name}-ktx`).length, 0);
|
||||
|
|
|
|||
|
|
@ -1,118 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { access } from 'node:fs/promises';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
import { packageArtifactLayout } from './package-artifacts.mjs';
|
||||
import { releaseReadinessReport } from './release-readiness.mjs';
|
||||
|
||||
export const NPM_PUBLISH_TIMEOUT_MS = 180_000;
|
||||
|
||||
export function resolvePublishMode(args = process.argv.slice(2)) {
|
||||
return { live: args.includes('--publish') };
|
||||
}
|
||||
|
||||
export function requireNpmPublicReleaseReady(report) {
|
||||
if (report.releaseMode !== 'npm-public-release-ready' || report.npmPublishEnabled !== true || !report.npmPublish) {
|
||||
throw new Error('release-policy.json must use npm-public-release-ready before publishing');
|
||||
}
|
||||
return report.npmPublish;
|
||||
}
|
||||
|
||||
export function buildNpmPublishCommand(tarballPath, publish, mode) {
|
||||
return {
|
||||
command: 'npm',
|
||||
args: [
|
||||
'publish',
|
||||
tarballPath,
|
||||
'--access',
|
||||
publish.access,
|
||||
'--tag',
|
||||
publish.tag,
|
||||
...(mode.live ? [] : ['--dry-run']),
|
||||
],
|
||||
env: publish.registry ? { npm_config_registry: publish.registry } : {},
|
||||
};
|
||||
}
|
||||
|
||||
async function assertFileExists(path) {
|
||||
try {
|
||||
await access(path);
|
||||
} catch {
|
||||
throw new Error(`Missing npm tarball: ${path}. Run pnpm run artifacts:check first.`);
|
||||
}
|
||||
}
|
||||
|
||||
async function runPublishCommand(command) {
|
||||
process.stdout.write(`$ ${command.command} ${command.args.join(' ')}\n`);
|
||||
|
||||
await new Promise((resolvePromise, reject) => {
|
||||
let settled = false;
|
||||
const child = spawn(command.command, command.args, {
|
||||
env: { ...process.env, ...command.env },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
const settle = (callback, value) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
callback(value);
|
||||
};
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill('SIGTERM');
|
||||
settle(reject, new Error(`Timed out after ${NPM_PUBLISH_TIMEOUT_MS}ms while publishing npm package`));
|
||||
}, NPM_PUBLISH_TIMEOUT_MS);
|
||||
|
||||
child.stdout.on('data', (chunk) => {
|
||||
process.stdout.write(chunk);
|
||||
});
|
||||
child.stderr.on('data', (chunk) => {
|
||||
process.stderr.write(chunk);
|
||||
});
|
||||
child.on('error', (error) => {
|
||||
settle(reject, error);
|
||||
});
|
||||
child.on('close', (code, signal) => {
|
||||
if (code === 0) {
|
||||
settle(resolvePromise);
|
||||
return;
|
||||
}
|
||||
settle(reject, new Error(`npm publish failed with ${signal ? `signal ${signal}` : `exit code ${code}`}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function publishPublicNpmPackage(options = {}) {
|
||||
const rootDir = options.rootDir;
|
||||
const mode = options.mode ?? resolvePublishMode(options.args);
|
||||
const report = await releaseReadinessReport(rootDir);
|
||||
const publish = requireNpmPublicReleaseReady(report);
|
||||
const layout = packageArtifactLayout(rootDir);
|
||||
const tarballPath = layout.cliTarball;
|
||||
|
||||
await assertFileExists(tarballPath);
|
||||
const command = buildNpmPublishCommand(tarballPath, publish, mode);
|
||||
await runPublishCommand(command);
|
||||
|
||||
process.stdout.write(
|
||||
mode.live
|
||||
? `Published ${publish.packageName}@${publish.version} with tag ${publish.tag}\n`
|
||||
: `Dry-run verified ${publish.packageName}@${publish.version} with tag ${publish.tag}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await publishPublicNpmPackage({ args: process.argv.slice(2) });
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
import {
|
||||
buildNpmPublishCommand,
|
||||
requireNpmPublicReleaseReady,
|
||||
resolvePublishMode,
|
||||
} from './publish-public-npm-package.mjs';
|
||||
|
||||
const readyReport = {
|
||||
releaseMode: 'npm-public-release-ready',
|
||||
npmPublishEnabled: true,
|
||||
npmPublish: {
|
||||
packageName: '@kaelio/ktx',
|
||||
version: '0.1.0-rc.1',
|
||||
access: 'public',
|
||||
tag: 'next',
|
||||
registry: null,
|
||||
},
|
||||
};
|
||||
|
||||
describe('resolvePublishMode', () => {
|
||||
it('dry-runs by default', () => {
|
||||
assert.deepEqual(resolvePublishMode([]), { live: false });
|
||||
});
|
||||
|
||||
it('requires an explicit flag for live publish', () => {
|
||||
assert.deepEqual(resolvePublishMode(['--publish']), { live: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('requireNpmPublicReleaseReady', () => {
|
||||
it('accepts the npm public release ready report', () => {
|
||||
assert.equal(requireNpmPublicReleaseReady(readyReport), readyReport.npmPublish);
|
||||
});
|
||||
|
||||
it('rejects artifact-only reports', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
requireNpmPublicReleaseReady({
|
||||
releaseMode: 'ci-artifact-only',
|
||||
npmPublishEnabled: false,
|
||||
npmPublish: null,
|
||||
}),
|
||||
/release-policy.json must use npm-public-release-ready before publishing/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildNpmPublishCommand', () => {
|
||||
it('builds a dry-run npm publish command by default', () => {
|
||||
assert.deepEqual(
|
||||
buildNpmPublishCommand('/repo/ktx/dist/artifacts/npm/kaelio-ktx-0.1.0-rc.1.tgz', readyReport.npmPublish, {
|
||||
live: false,
|
||||
}),
|
||||
{
|
||||
command: 'npm',
|
||||
args: [
|
||||
'publish',
|
||||
'/repo/ktx/dist/artifacts/npm/kaelio-ktx-0.1.0-rc.1.tgz',
|
||||
'--access',
|
||||
'public',
|
||||
'--tag',
|
||||
'next',
|
||||
'--dry-run',
|
||||
],
|
||||
env: {},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('omits dry-run only for explicit live publish', () => {
|
||||
assert.deepEqual(
|
||||
buildNpmPublishCommand('/repo/ktx/dist/artifacts/npm/kaelio-ktx-0.1.0-rc.1.tgz', readyReport.npmPublish, {
|
||||
live: true,
|
||||
}).args,
|
||||
[
|
||||
'publish',
|
||||
'/repo/ktx/dist/artifacts/npm/kaelio-ktx-0.1.0-rc.1.tgz',
|
||||
'--access',
|
||||
'public',
|
||||
'--tag',
|
||||
'next',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it('uses npm_config_registry when a registry is configured', () => {
|
||||
const publish = {
|
||||
...readyReport.npmPublish,
|
||||
registry: 'https://registry.npmjs.org/',
|
||||
};
|
||||
|
||||
assert.deepEqual(
|
||||
buildNpmPublishCommand('/repo/ktx/dist/artifacts/npm/kaelio-ktx-0.1.0-rc.1.tgz', publish, { live: false }).env,
|
||||
{ npm_config_registry: 'https://registry.npmjs.org/' },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('package script', () => {
|
||||
it('registers release:npm-publish', async () => {
|
||||
const packageJson = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
|
||||
|
||||
assert.equal(packageJson.scripts['release:npm-publish'], 'node scripts/publish-public-npm-package.mjs');
|
||||
});
|
||||
});
|
||||
|
|
@ -9,19 +9,22 @@ describe('release workflow', () => {
|
|||
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, /release_kind:[\s\S]*?default: "stable"/);
|
||||
assert.match(workflow, /options:\n - stable\n - rc/);
|
||||
assert.match(workflow, /force_release:/);
|
||||
assert.match(workflow, /publish_live:/);
|
||||
assert.match(workflow, /default: false/);
|
||||
assert.match(workflow, /publish_live:[\s\S]*?default: true/);
|
||||
assert.match(workflow, /^ contents: write$/m);
|
||||
assert.match(workflow, /^ id-token: write$/m);
|
||||
assert.match(workflow, /fetch-depth: 0/);
|
||||
assert.match(workflow, /registry-url: "https:\/\/registry\.npmjs\.org"/);
|
||||
assert.match(workflow, /Prepare first stable release floor/);
|
||||
assert.match(workflow, /git tag v0\.0\.0 "\$\{root_commit\}"/);
|
||||
assert.match(workflow, /KTX_STABLE_RELEASE_FLOOR_TAG=v0\.0\.0/);
|
||||
assert.doesNotMatch(workflow, /Prepare first stable release floor/);
|
||||
assert.doesNotMatch(workflow, /git tag v0\.0\.0/);
|
||||
assert.doesNotMatch(workflow, /KTX_STABLE_RELEASE_FLOOR_TAG/);
|
||||
assert.match(workflow, /Prepare next prerelease branch/);
|
||||
assert.match(workflow, /git checkout -B "\$\{KTX_PRERELEASE_BRANCH\}"/);
|
||||
assert.match(workflow, /Prepare npm package root for release verification/);
|
||||
assert.match(workflow, /dist\/public-npm-package\/package\.json/);
|
||||
assert.match(workflow, /GITHUB_REF="refs\/heads\/\$\{KTX_PRERELEASE_BRANCH\}"/);
|
||||
assert.match(workflow, /pnpm run semantic-release:dry-run/);
|
||||
assert.match(workflow, /pnpm run semantic-release$/m);
|
||||
|
|
|
|||
|
|
@ -137,7 +137,6 @@ function createReleaseConfig(env = process.env) {
|
|||
},
|
||||
},
|
||||
],
|
||||
'./scripts/semantic-release-version-policy.cjs',
|
||||
'@semantic-release/changelog',
|
||||
[
|
||||
'@semantic-release/exec',
|
||||
|
|
@ -147,10 +146,19 @@ function createReleaseConfig(env = process.env) {
|
|||
'pnpm run artifacts:check',
|
||||
'pnpm run release:readiness',
|
||||
].join(' && '),
|
||||
publishCmd: [
|
||||
'pnpm run release:npm-publish -- --publish',
|
||||
'pnpm run release:published-smoke',
|
||||
].join(' && '),
|
||||
},
|
||||
],
|
||||
[
|
||||
'@semantic-release/npm',
|
||||
{
|
||||
pkgRoot: 'dist/public-npm-package',
|
||||
tarballDir: 'dist/artifacts/npm',
|
||||
},
|
||||
],
|
||||
[
|
||||
'@semantic-release/exec',
|
||||
{
|
||||
publishCmd: 'pnpm run release:published-smoke',
|
||||
},
|
||||
],
|
||||
[
|
||||
|
|
|
|||
|
|
@ -19,10 +19,21 @@ describe('semantic-release config', () => {
|
|||
]);
|
||||
|
||||
const config = createReleaseConfig({ KTX_RELEASE_KIND: 'rc', GITHUB_REF_NAME: 'main' });
|
||||
assert.deepEqual(
|
||||
config.plugins.find((plugin) => Array.isArray(plugin) && plugin[0] === '@semantic-release/npm'),
|
||||
[
|
||||
'@semantic-release/npm',
|
||||
{
|
||||
pkgRoot: 'dist/public-npm-package',
|
||||
tarballDir: 'dist/artifacts/npm',
|
||||
},
|
||||
],
|
||||
);
|
||||
assert.match(
|
||||
releaseExecOptions(config).prepareCmd,
|
||||
/update-public-release-version\.mjs "\$\{nextRelease\.version\}" "next"/,
|
||||
);
|
||||
assert.doesNotMatch(releaseExecOptions(config).publishCmd ?? '', /release:npm-publish/);
|
||||
});
|
||||
|
||||
it('configures stable releases only from main with latest tag', () => {
|
||||
|
|
@ -35,7 +46,7 @@ describe('semantic-release config', () => {
|
|||
releaseExecOptions(config).prepareCmd,
|
||||
/update-public-release-version\.mjs "\$\{nextRelease\.version\}" "latest"/,
|
||||
);
|
||||
assert.ok(config.plugins.includes('./scripts/semantic-release-version-policy.cjs'));
|
||||
assert.equal(config.plugins.includes('./scripts/semantic-release-version-policy.cjs'), false);
|
||||
});
|
||||
|
||||
it('rejects stable releases from non-main branches', () => {
|
||||
|
|
@ -63,5 +74,12 @@ describe('semantic-release config', () => {
|
|||
analyzer[1].releaseRules.some((rule) => rule.release === 'major'),
|
||||
false,
|
||||
);
|
||||
assert.deepEqual(
|
||||
analyzer[1].releaseRules.filter((rule) => rule.breaking || rule.type === 'major'),
|
||||
[
|
||||
{ breaking: true, release: 'minor' },
|
||||
{ type: 'major', release: 'minor' },
|
||||
],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,114 +0,0 @@
|
|||
const { readFileSync } = require('node:fs');
|
||||
const { join } = require('node:path');
|
||||
|
||||
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-]+)*)?$/;
|
||||
const FIRST_STABLE_RELEASE_FLOOR_VERSION = '0.0.0';
|
||||
|
||||
function parseSemver(version) {
|
||||
const match = SEMVER_PATTERN.exec(version);
|
||||
if (!match) {
|
||||
throw new Error(`Invalid public npm package version: ${version}`);
|
||||
}
|
||||
|
||||
return {
|
||||
major: Number(match[1]),
|
||||
minor: Number(match[2]),
|
||||
patch: Number(match[3]),
|
||||
prerelease: match[4] ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function readReleasePolicy(cwd) {
|
||||
return JSON.parse(readFileSync(join(cwd, 'release-policy.json'), 'utf8'));
|
||||
}
|
||||
|
||||
function releaseKind(env) {
|
||||
return env.KTX_RELEASE_KIND || env.INPUT_RELEASE_KIND || 'rc';
|
||||
}
|
||||
|
||||
function stableBaseVersion(version) {
|
||||
const parsed = parseSemver(version);
|
||||
return `${parsed.major}.${parsed.minor}.${parsed.patch}`;
|
||||
}
|
||||
|
||||
function isFirstStableReleaseFloor(context) {
|
||||
return (
|
||||
releaseKind(context.env) === 'stable' &&
|
||||
context.env.KTX_STABLE_RELEASE_FLOOR_TAG &&
|
||||
context.lastRelease.version === FIRST_STABLE_RELEASE_FLOOR_VERSION &&
|
||||
context.lastRelease.gitTag === context.env.KTX_STABLE_RELEASE_FLOOR_TAG
|
||||
);
|
||||
}
|
||||
|
||||
function analyzeCommits(config, context) {
|
||||
if (!isFirstStableReleaseFloor(context)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
context.logger.log('Using temporary stable release floor to publish 0.1.0');
|
||||
return 'minor';
|
||||
}
|
||||
|
||||
function assertNoAutomaticMajorRelease(context, policyVersion) {
|
||||
const policy = parseSemver(policyVersion);
|
||||
const next = parseSemver(context.nextRelease.version);
|
||||
if (next.major <= policy.major) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
[
|
||||
`Refusing automatic major release ${context.nextRelease.version}.`,
|
||||
`release-policy.json is still on major ${policy.major}.`,
|
||||
'Update release-policy.json manually before publishing a new major version.',
|
||||
].join(' '),
|
||||
);
|
||||
}
|
||||
|
||||
function assertStableReleaseFloorTarget(context, policyVersion) {
|
||||
if (!isFirstStableReleaseFloor(context)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const expectedVersion = stableBaseVersion(policyVersion);
|
||||
if (context.nextRelease.version !== expectedVersion) {
|
||||
throw new Error(
|
||||
`Stable release floor expected ${expectedVersion}, got ${context.nextRelease.version}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function verifyRelease(config, context) {
|
||||
const policy = readReleasePolicy(context.cwd);
|
||||
const policyVersion = policy.publicNpmPackageVersion;
|
||||
|
||||
assertNoAutomaticMajorRelease(context, policyVersion);
|
||||
assertStableReleaseFloorTarget(context, policyVersion);
|
||||
}
|
||||
|
||||
function prepare(config, context) {
|
||||
const floorTag = context.env.KTX_STABLE_RELEASE_FLOOR_TAG;
|
||||
if (!floorTag) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { execFileSync } = require('node:child_process');
|
||||
execFileSync('git', ['tag', '-d', floorTag], {
|
||||
cwd: context.cwd,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
context.logger.log(`Deleted temporary stable release floor tag ${floorTag}`);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
FIRST_STABLE_RELEASE_FLOOR_VERSION,
|
||||
analyzeCommits,
|
||||
assertNoAutomaticMajorRelease,
|
||||
assertStableReleaseFloorTarget,
|
||||
isFirstStableReleaseFloor,
|
||||
parseSemver,
|
||||
prepare,
|
||||
stableBaseVersion,
|
||||
verifyRelease,
|
||||
};
|
||||
|
|
@ -1,123 +0,0 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { createRequire } from 'node:module';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const {
|
||||
analyzeCommits,
|
||||
parseSemver,
|
||||
stableBaseVersion,
|
||||
verifyRelease,
|
||||
} = require('./semantic-release-version-policy.cjs');
|
||||
|
||||
async function writePolicy(root, version) {
|
||||
await mkdir(root, { recursive: true });
|
||||
await writeFile(
|
||||
join(root, 'release-policy.json'),
|
||||
`${JSON.stringify({ publicNpmPackageVersion: version }, null, 2)}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
function releaseContext(root, overrides = {}) {
|
||||
return {
|
||||
cwd: root,
|
||||
env: { KTX_RELEASE_KIND: 'stable' },
|
||||
lastRelease: {},
|
||||
logger: { log() {} },
|
||||
nextRelease: {
|
||||
version: '1.0.0',
|
||||
gitTag: 'v1.0.0',
|
||||
name: 'v1.0.0',
|
||||
},
|
||||
options: { tagFormat: 'v${version}' },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('semantic-release version policy', () => {
|
||||
it('parses semver versions used by public release metadata', () => {
|
||||
assert.deepEqual(parseSemver('0.1.0-rc.6'), {
|
||||
major: 0,
|
||||
minor: 1,
|
||||
patch: 0,
|
||||
prerelease: 'rc.6',
|
||||
});
|
||||
assert.equal(stableBaseVersion('0.1.0-rc.6'), '0.1.0');
|
||||
});
|
||||
|
||||
it('uses the temporary stable release floor to make 0.1.0 a minor release', async () => {
|
||||
const context = releaseContext('/repo/ktx', {
|
||||
env: {
|
||||
KTX_RELEASE_KIND: 'stable',
|
||||
KTX_STABLE_RELEASE_FLOOR_TAG: 'v0.0.0',
|
||||
},
|
||||
lastRelease: {
|
||||
version: '0.0.0',
|
||||
gitTag: 'v0.0.0',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(analyzeCommits({}, context), 'minor');
|
||||
});
|
||||
|
||||
it('accepts the first stable release from the current public rc base version', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'ktx-release-version-policy-'));
|
||||
try {
|
||||
await writePolicy(root, '0.1.0-rc.6');
|
||||
const context = releaseContext(root, {
|
||||
env: {
|
||||
KTX_RELEASE_KIND: 'stable',
|
||||
KTX_STABLE_RELEASE_FLOOR_TAG: 'v0.0.0',
|
||||
},
|
||||
lastRelease: {
|
||||
version: '0.0.0',
|
||||
gitTag: 'v0.0.0',
|
||||
},
|
||||
nextRelease: {
|
||||
version: '0.1.0',
|
||||
gitTag: 'v0.1.0',
|
||||
name: 'v0.1.0',
|
||||
},
|
||||
});
|
||||
|
||||
verifyRelease({}, context);
|
||||
|
||||
assert.equal(context.nextRelease.version, '0.1.0');
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects automatic major releases until release metadata is manually advanced', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'ktx-release-version-policy-'));
|
||||
try {
|
||||
await writePolicy(root, '0.1.0');
|
||||
const context = releaseContext(root, {
|
||||
lastRelease: { gitTag: 'v0.1.0' },
|
||||
});
|
||||
|
||||
assert.throws(() => verifyRelease({}, context), /Refusing automatic major release 1\.0\.0/);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('allows major releases when release metadata was manually advanced first', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'ktx-release-version-policy-'));
|
||||
try {
|
||||
await writePolicy(root, '1.0.0');
|
||||
const context = releaseContext(root, {
|
||||
lastRelease: { gitTag: 'v0.1.0' },
|
||||
});
|
||||
|
||||
verifyRelease({}, context);
|
||||
|
||||
assert.equal(context.nextRelease.version, '1.0.0');
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue