mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-08-30 04:19:27 +00:00
fix: restore automatic tag versioning
This commit is contained in:
+14
-8
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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()
|
||||
]);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user