fix: restore automatic tag versioning

This commit is contained in:
KoalaDev
2026-08-25 14:42:24 +02:00
parent eb925eca5e
commit 5fffeadded
13 changed files with 361 additions and 118 deletions
+14 -8
View File
@@ -10,8 +10,7 @@ npm run verify
npm run lint
npm run test:unit
npm run test:coverage
npm run prepare:release -- 3.1.5
npm run release:gate -- 3.1.5 --candidate
npm run release:gate -- 3.1.6 --candidate
```
- `npm run build:extension` runs `scripts/build-extension.cjs`.
@@ -19,8 +18,14 @@ npm run release:gate -- 3.1.5 --candidate
- `npm run lint` runs ESLint across the repository.
- `npm run test:unit` runs Vitest tests.
- `npm run test:coverage` runs the same tests with the enforced coverage floor.
- `npm run prepare:release -- MAJOR.MINOR.PATCH` updates every release-version source consistently before the release PR.
- `npm run release:gate -- MAJOR.MINOR.PATCH --candidate` runs the complete release candidate in the lockfile-matched official Playwright Linux/AMD64 image, then builds and health-smokes the relay container. After merge, omit `--candidate`; final mode additionally requires clean current `main`, exact `origin/main`, and successful `verify`, `node20`, and `e2e` checks while simulating the release workflow's own pending preflight check.
- `scripts/prepare-release.mjs` is an internal tag-workflow helper. It updates
every release-version source from the validated tag and accepts the tagged
commit timestamp for deterministic retries. Maintainers do not run it before
tagging.
- `npm run release:gate -- MAJOR.MINOR.PATCH --candidate` is an optional
Linux/AMD64 parity diagnostic for release-code changes. It prepares the target
version only inside its isolated clone, then runs verification, browser E2E,
relay build, and health smoke. It is not required for Markdown-only changes.
## build-extension.cjs
@@ -100,10 +105,11 @@ integration gate. New unclassified files fail `npm run verify`.
## Published Release Verification
Before publication, the release workflow validates the exact annotated SemVer
tag, requires it to point at current `origin/main`, requires successful
`verify`, `node20`, and `e2e` checks, and runs the complete gates again. It then
creates a draft release, publishes and smoke-tests the relay image, and only
afterwards makes the GitHub Release public. The published-asset gate runs:
tag and required checks, prepares and validates every version source, pushes
the generated version commit directly to `main`, and checks out that exact
commit for all verification and builds. It then creates a draft release,
publishes and smoke-tests the relay image, and only afterwards makes the GitHub
Release public. The published-asset gate runs:
```bash
node scripts/verify-published-release.mjs vMAJOR.MINOR.PATCH --repo Shik3i/KoalaSync
+20 -11
View File
@@ -15,57 +15,62 @@ export function replaceExactly(text, pattern, replacement, label) {
return text.replace(pattern, replacement);
}
function writeJson(relativePath, update) {
const absolutePath = path.join(repoRoot, relativePath);
function writeJson(root, relativePath, update) {
const absolutePath = path.join(root, relativePath);
const value = JSON.parse(fs.readFileSync(absolutePath, 'utf8'));
update(value);
fs.writeFileSync(absolutePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
}
function updateText(relativePath, pattern, replacement, label) {
const absolutePath = path.join(repoRoot, relativePath);
function updateText(root, relativePath, pattern, replacement, label) {
const absolutePath = path.join(root, relativePath);
const current = fs.readFileSync(absolutePath, 'utf8');
fs.writeFileSync(absolutePath, replaceExactly(current, pattern, replacement, label), 'utf8');
}
export function prepareRelease(version, date = new Date()) {
export function prepareRelease(version, date = new Date(), root = repoRoot) {
versionFromTag(`v${version}`);
const timestamp = date.toISOString().replace(/\.\d{3}Z$/u, 'Z');
writeJson('package.json', value => { value.version = version; });
writeJson('package-lock.json', value => {
writeJson(root, 'package.json', value => { value.version = version; });
writeJson(root, 'package-lock.json', value => {
value.version = version;
value.packages[''].version = version;
});
writeJson('extension/manifest.base.json', value => { value.version = version; });
writeJson('website/version.json', value => {
writeJson(root, 'extension/manifest.base.json', value => { value.version = version; });
writeJson(root, 'website/version.json', value => {
value.version = version;
value.date = timestamp;
});
updateText(
root,
'shared/constants.js',
/export const APP_VERSION = ["'][^"']+["'];/gu,
`export const APP_VERSION = "${version}";`,
'shared/constants.js'
);
updateText(
root,
'website/template.html',
/"softwareVersion": "[^"]+"/gu,
`"softwareVersion": "${version}"`,
'website/template.html'
);
updateText(
root,
'website/llms.txt',
/Current website release: .+/gu,
`Current website release: ${version}`,
'website/llms.txt'
);
updateText(
root,
'README.md',
/Release-v\d+\.\d+\.\d+-blue/gu,
`Release-v${version}-blue`,
'README.md release badge'
);
updateText(
root,
'README.md',
/New v\d+\.\d+\.\d+ Release!/gu,
`New v${version} Release!`,
@@ -77,8 +82,12 @@ export function prepareRelease(version, date = new Date()) {
const isMainModule = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
if (isMainModule) {
try {
if (process.argv.length !== 3) throw new Error('Usage: npm run prepare:release -- MAJOR.MINOR.PATCH');
prepareRelease(process.argv[2]);
if (process.argv.length < 3 || process.argv.length > 4) {
throw new Error('Usage: prepare-release.mjs MAJOR.MINOR.PATCH [ISO_TIMESTAMP]');
}
const date = process.argv[3] ? new Date(process.argv[3]) : new Date();
if (Number.isNaN(date.getTime())) throw new Error(`Invalid release timestamp: ${process.argv[3]}`);
prepareRelease(process.argv[2], date);
} catch (error) {
console.error(`Release preparation failed: ${error.message}`);
process.exitCode = 1;
+77 -2
View File
@@ -1,5 +1,47 @@
import { describe, expect, it } from 'vitest';
import { replaceExactly } from './prepare-release.mjs';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, describe, expect, it } from 'vitest';
import { validateReleaseSourceVersion } from './release-preflight.mjs';
import { prepareRelease, replaceExactly } from './prepare-release.mjs';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const temporaryDirectories = [];
const releaseSourcePaths = [
'README.md',
'extension/manifest.base.json',
'package.json',
'package-lock.json',
'shared/constants.js',
'website/llms.txt',
'website/template.html',
'website/version.json'
];
function createReleaseFixture() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'koalasync-prepare-release-'));
temporaryDirectories.push(root);
for (const relativePath of releaseSourcePaths) {
const target = path.join(root, relativePath);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.copyFileSync(path.join(repoRoot, relativePath), target);
}
return root;
}
function readFixture(root) {
return Object.fromEntries(releaseSourcePaths.map(relativePath => [
relativePath,
fs.readFileSync(path.join(root, relativePath), 'utf8')
]));
}
afterEach(() => {
for (const directory of temporaryDirectories.splice(0)) {
fs.rmSync(directory, { recursive: true, force: true });
}
});
describe('release preparation helpers', () => {
it('replaces one and only one version marker', () => {
@@ -12,4 +54,37 @@ describe('release preparation helpers', () => {
expect(() => replaceExactly('version=1 version=2', /version=\d+/gu, 'version=3', 'fixture'))
.toThrow('fixture must contain exactly one release-version marker');
});
it('updates and validates every release-version source, including both README markers', () => {
const root = createReleaseFixture();
prepareRelease('9.8.7', new Date('2030-04-05T06:07:08Z'), root);
expect(() => validateReleaseSourceVersion('9.8.7', root)).not.toThrow();
expect(fs.readFileSync(path.join(root, 'README.md'), 'utf8')).toContain('Release-v9.8.7-blue');
expect(fs.readFileSync(path.join(root, 'README.md'), 'utf8')).toContain('New v9.8.7 Release!');
expect(JSON.parse(fs.readFileSync(path.join(root, 'website/version.json'), 'utf8')).date)
.toBe('2030-04-05T06:07:08Z');
});
it('is deterministic when repeated with the tag timestamp and does not duplicate markers', () => {
const root = createReleaseFixture();
const timestamp = new Date('2031-02-03T04:05:06Z');
prepareRelease('9.8.7', timestamp, root);
const once = readFixture(root);
prepareRelease('9.8.7', timestamp, root);
expect(readFixture(root)).toEqual(once);
expect(once['README.md'].match(/Release-v9\.8\.7-blue/gu)).toHaveLength(1);
expect(once['README.md'].match(/New v9\.8\.7 Release!/gu)).toHaveLength(1);
});
it('rejects invalid versions before changing release sources', () => {
const root = createReleaseFixture();
const before = readFixture(root);
expect(() => prepareRelease('9.8.7;echo-unsafe', new Date('2030-01-01T00:00:00Z'), root))
.toThrow('vMAJOR.MINOR.PATCH');
expect(readFixture(root)).toEqual(before);
});
});
+23 -2
View File
@@ -7,7 +7,6 @@ import path from 'node:path';
import { versionFromTag } from './release-artifact-checks.mjs';
import {
parseCheckRuns,
validateReleaseSourceVersion,
validateRequiredChecks
} from './release-preflight.mjs';
@@ -51,6 +50,8 @@ export function linuxGateCommand() {
return [
'git clone --no-local /src /work',
'cd /work',
'node scripts/prepare-release.mjs "$RELEASE_VERSION" "2030-01-01T00:00:00Z"',
'node scripts/release-preflight.mjs --sources "$RELEASE_VERSION"',
'npm ci',
'npm ci --prefix server',
'npm run verify',
@@ -76,6 +77,26 @@ export function validateReleaseWorkflowContract(text) {
if (/ghcr\.io\/\$\{\{\s*github\.repository\s*\}\}/u.test(workflow)) {
throw new Error('release workflow must not derive a Docker image from case-preserving github.repository');
}
for (const marker of [
'prepare-release:',
'node scripts/prepare-release.mjs "$VERSION" "$RELEASE_TIMESTAMP"',
'node scripts/release-preflight.mjs --sources "$VERSION"',
'git commit -m "chore(release): update versions to v$VERSION [skip ci]"',
'git push origin HEAD:main',
'needs: [prepare-release, verify-prepared-release, release-extension-draft, release-server]',
'gh release edit "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --draft=false --verify-tag'
]) {
if (!workflow.includes(marker)) {
throw new Error(`release workflow must preserve automatic tag versioning: ${marker}`);
}
}
const preparedCheckout = 'ref: ${{ needs.prepare-release.outputs.prepared-commit }}';
if ((workflow.match(new RegExp(preparedCheckout.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'), 'gu')) || []).length < 3) {
throw new Error('release workflow must use the prepared commit for verification and all release builds');
}
if (/git push origin HEAD:main\s*(?:\|\||;\s*true)/u.test(workflow)) {
throw new Error('release workflow must stop when the automatic main push fails');
}
return image;
}
@@ -134,7 +155,6 @@ async function smokeRelayImage(image) {
export async function runReleaseGate({ version, candidate }) {
assertCleanTree();
validateReleaseSourceVersion(version, repoRoot);
validateReleaseWorkflowContract(fs.readFileSync(
path.join(repoRoot, '.github/workflows/release.yml'), 'utf8'
));
@@ -145,6 +165,7 @@ export async function runReleaseGate({ version, candidate }) {
run('docker', ['pull', '--platform', 'linux/amd64', playwrightImage]);
run('docker', [
'run', '--rm', '--platform', 'linux/amd64', '--ipc=host', '--env', 'CI=1',
'--env', `RELEASE_VERSION=${version}`,
'--volume', `${repoRoot}:/src:ro`, playwrightImage,
'bash', '-lc', linuxGateCommand()
]);
+27 -1
View File
@@ -1,3 +1,4 @@
import fs from 'node:fs';
import { describe, expect, it } from 'vitest';
import {
linuxGateCommand,
@@ -33,7 +34,17 @@ describe('local release gate contract', () => {
const valid = [
'IMAGE: ghcr.io/shik3i/koalasync',
'images: ${{ env.IMAGE }}',
'subject-name: ${{ env.IMAGE }}'
'subject-name: ${{ env.IMAGE }}',
'prepare-release:',
'node scripts/prepare-release.mjs "$VERSION" "$RELEASE_TIMESTAMP"',
'node scripts/release-preflight.mjs --sources "$VERSION"',
'git commit -m "chore(release): update versions to v$VERSION [skip ci]"',
'git push origin HEAD:main',
'ref: ${{ needs.prepare-release.outputs.prepared-commit }}',
'ref: ${{ needs.prepare-release.outputs.prepared-commit }}',
'ref: ${{ needs.prepare-release.outputs.prepared-commit }}',
'needs: [prepare-release, verify-prepared-release, release-extension-draft, release-server]',
'gh release edit "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --draft=false --verify-tag'
].join('\n');
expect(validateReleaseWorkflowContract(valid)).toBe('ghcr.io/shik3i/koalasync');
expect(() => validateReleaseWorkflowContract(valid.replace(
@@ -44,10 +55,25 @@ describe('local release gate contract', () => {
.toThrow('case-preserving github.repository');
});
it('enforces the automatic version commit, direct push, prepared source, and final publication contract', () => {
const workflow = fs.readFileSync('.github/workflows/release.yml', 'utf8');
expect(validateReleaseWorkflowContract(workflow)).toBe('ghcr.io/shik3i/koalasync');
expect(() => validateReleaseWorkflowContract(workflow.replace(
'git push origin HEAD:main',
'git push origin HEAD:release'
))).toThrow('automatic tag versioning');
expect(() => validateReleaseWorkflowContract(workflow.replace(
'git push origin HEAD:main',
'git push origin HEAD:main || true'
))).toThrow('stop when the automatic main push fails');
});
it('runs the complete CI-equivalent dependency, verify, and browser sequence', () => {
expect(linuxGateCommand()).toBe([
'git clone --no-local /src /work',
'cd /work',
'node scripts/prepare-release.mjs "$RELEASE_VERSION" "2030-01-01T00:00:00Z"',
'node scripts/release-preflight.mjs --sources "$RELEASE_VERSION"',
'npm ci',
'npm ci --prefix server',
'npm run verify',
+53 -6
View File
@@ -51,6 +51,14 @@ export function validateVersionSnapshot(expectedVersion, snapshot) {
}
}
function versionFromMarker(text, pattern, label) {
const matches = [...String(text).matchAll(pattern)];
if (matches.length !== 1) {
throw new Error(`${label} must contain exactly one release-version marker`);
}
return matches[0][1];
}
export function validateReleaseSourceVersion(expectedVersion, repoRoot = process.cwd()) {
const readJson = relativePath => JSON.parse(fs.readFileSync(path.join(repoRoot, relativePath), 'utf8'));
const packageJson = readJson('package.json');
@@ -58,21 +66,46 @@ export function validateReleaseSourceVersion(expectedVersion, repoRoot = process
const manifest = readJson('extension/manifest.base.json');
const websiteVersion = readJson('website/version.json');
const constants = fs.readFileSync(path.join(repoRoot, 'shared/constants.js'), 'utf8');
const appVersion = /export const APP_VERSION = ["']([^"']+)["']/u.exec(constants)?.[1] || '';
const websiteTemplate = fs.readFileSync(path.join(repoRoot, 'website/template.html'), 'utf8');
const websiteLlms = fs.readFileSync(path.join(repoRoot, 'website/llms.txt'), 'utf8');
const readme = fs.readFileSync(path.join(repoRoot, 'README.md'), 'utf8');
validateVersionSnapshot(expectedVersion, {
'package.json': packageJson.version,
'package-lock.json': packageLock.version,
'package-lock root package': packageLock.packages?.['']?.version,
'extension manifest': manifest.version,
'shared constants': appVersion,
'website/version.json': websiteVersion.version
'shared constants': versionFromMarker(
constants,
/export const APP_VERSION = ["']([^"']+)["'];/gu,
'shared/constants.js'
),
'website/version.json': websiteVersion.version,
'website template': versionFromMarker(
websiteTemplate,
/"softwareVersion": "([^"]+)"/gu,
'website/template.html'
),
'website llms': versionFromMarker(
websiteLlms,
/Current website release: (\d+\.\d+\.\d+)/gu,
'website/llms.txt'
),
'README release badge': versionFromMarker(
readme,
/Release-v(\d+\.\d+\.\d+)-blue/gu,
'README.md release badge'
),
'README release banner': versionFromMarker(
readme,
/New v(\d+\.\d+\.\d+) Release!/gu,
'README.md release banner'
)
});
}
export function verifyReleaseRef({ tag, repo }) {
const version = versionFromTag(tag);
validateRepositoryName(repo);
validateReleaseSourceVersion(version);
const tagRef = `refs/tags/${tag}`;
if (run('git', ['cat-file', '-t', tagRef]) !== 'tag') {
throw new Error(`${tag} must be an annotated tag`);
@@ -88,16 +121,30 @@ export function verifyReleaseRef({ tag, repo }) {
'--jq', '.check_runs[] | [.name, .conclusion, .html_url] | @tsv'
]));
validateRequiredChecks(checks);
return { version, tagCommit };
const releaseTimestamp = run('git', ['show', '-s', '--format=%cI', tagCommit]);
return { version, tagCommit, releaseTimestamp };
}
function main() {
if (process.argv[2] === '--sources') {
if (process.argv.length !== 4) {
throw new Error('Usage: release-preflight.mjs --sources MAJOR.MINOR.PATCH');
}
const version = versionFromTag(`v${process.argv[3]}`);
validateReleaseSourceVersion(version);
console.log(`Release sources match v${version}`);
return;
}
const tag = process.env.GITHUB_REF_NAME || '';
const repo = process.env.GITHUB_REPOSITORY || '';
const outputPath = process.env.GITHUB_OUTPUT || '';
const result = verifyReleaseRef({ tag, repo });
if (!outputPath) throw new Error('GITHUB_OUTPUT is required');
fs.appendFileSync(outputPath, `version=${result.version}\ntag_commit=${result.tagCommit}\n`, 'utf8');
fs.appendFileSync(
outputPath,
`version=${result.version}\ntag_commit=${result.tagCommit}\nrelease_timestamp=${result.releaseTimestamp}\n`,
'utf8'
);
console.log(`Release preflight accepted ${tag} at ${result.tagCommit}`);
}
+1 -1
View File
@@ -62,7 +62,7 @@ describe('release preflight helpers', () => {
expect(() => parseCheckRuns('verify')).toThrow('Invalid check-run record');
});
it('requires every release source to already match the tag version', () => {
it('requires every prepared release source to match the tag version', () => {
expect(() => validateVersionSnapshot('3.1.5', {
package: '3.1.5',
manifest: '3.1.5'