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
+74 -5
View File
@@ -18,6 +18,7 @@ jobs:
outputs:
version: ${{ steps.release-ref.outputs.version }}
tag-commit: ${{ steps.release-ref.outputs.tag_commit }}
release-timestamp: ${{ steps.release-ref.outputs.release_timestamp }}
steps:
- name: Checkout release tag
uses: actions/checkout@v7
@@ -39,6 +40,71 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
prepare-release:
needs: preflight
runs-on: ubuntu-latest
permissions:
contents: write
outputs:
prepared-commit: ${{ steps.version-commit.outputs.prepared_commit }}
steps:
- name: Checkout release tag
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '24'
- name: Prepare and validate every release-version source
env:
VERSION: ${{ needs.preflight.outputs.version }}
RELEASE_TIMESTAMP: ${{ needs.preflight.outputs.release-timestamp }}
run: |
node scripts/prepare-release.mjs "$VERSION" "$RELEASE_TIMESTAMP"
node scripts/release-preflight.mjs --sources "$VERSION"
- name: Commit and push prepared release to main
id: version-commit
env:
VERSION: ${{ needs.preflight.outputs.version }}
run: |
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git add extension/manifest.base.json shared/constants.js package.json package-lock.json website/version.json website/template.html website/llms.txt README.md
if git diff --cached --quiet; then
echo "Release versions already match v$VERSION"
else
git commit -m "chore(release): update versions to v$VERSION [skip ci]"
fi
git push origin HEAD:main
echo "prepared_commit=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
verify-prepared-release:
needs: [preflight, prepare-release]
runs-on: ubuntu-latest
steps:
- name: Checkout prepared release commit
uses: actions/checkout@v7
with:
ref: ${{ needs.prepare-release.outputs.prepared-commit }}
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '24'
cache: 'npm'
cache-dependency-path: |
package-lock.json
server/package-lock.json
- name: Validate every prepared version source
env:
VERSION: ${{ needs.preflight.outputs.version }}
run: node scripts/release-preflight.mjs --sources "$VERSION"
- name: Install root dependencies
run: npm ci
@@ -73,16 +139,17 @@ jobs:
exit 1
release-extension-draft:
needs: preflight
needs: [preflight, prepare-release, verify-prepared-release]
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
attestations: write
steps:
- name: Checkout release tag
- name: Checkout prepared release commit
uses: actions/checkout@v7
with:
ref: ${{ needs.prepare-release.outputs.prepared-commit }}
fetch-depth: 0
- name: Set up Node.js
@@ -137,7 +204,7 @@ jobs:
if-no-files-found: error
release-server:
needs: [preflight, release-extension-draft]
needs: [preflight, prepare-release, verify-prepared-release, release-extension-draft]
runs-on: ubuntu-latest
env:
IMAGE: ghcr.io/shik3i/koalasync
@@ -147,8 +214,10 @@ jobs:
id-token: write
attestations: write
steps:
- name: Checkout release tag
- name: Checkout prepared release commit
uses: actions/checkout@v7
with:
ref: ${{ needs.prepare-release.outputs.prepared-commit }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
@@ -212,7 +281,7 @@ jobs:
exit 1
finalize-release:
needs: [preflight, release-extension-draft, release-server]
needs: [prepare-release, verify-prepared-release, release-extension-draft, release-server]
runs-on: ubuntu-latest
permissions:
contents: write
+15 -17
View File
@@ -8,33 +8,31 @@ These rules are mandatory for every automated agent working in this repository.
- Inspect branch, remote tracking, dirty state, and relevant release workflow.
- Preserve unrelated work and stage only reviewed paths.
## Before claiming a release is ready
## Release verification
- Never treat a host macOS browser run as GitHub Linux parity.
- On a release branch, commit all intended changes and run:
`npm run release:gate -- MAJOR.MINOR.PATCH --candidate`
- This command must use the official lockfile-matched Playwright image with
`linux/amd64`, Ubuntu Noble, and `CI=1`; it must run clean installs, full
verification, all browser E2E tests, the relay image build, and health smoke.
- A failed, interrupted, ARM64, skipped, or partial run is not a passing gate.
- Run checks proportionate to the changed files. Markdown-only changes do not
require browser E2E or `release:gate`.
- `npm run release:gate -- MAJOR.MINOR.PATCH [--candidate]` remains available
when release code itself needs Linux/AMD64 parity validation. It prepares the
requested version only inside its isolated clone.
- All OCI image references must use the canonical lowercase
`ghcr.io/shik3i/koalasync`; never construct Docker references from the
case-preserving `${{ github.repository }}` value. The local gate enforces this.
- Do not say "release-ready" until the candidate gate and PR required checks
are green for the exact commit.
## Before pushing a release tag
- Merge through a PR; resolve every review thread.
- Fast-forward local `main` and confirm a clean tree at exact `origin/main`.
- Wait for `verify`, `node20`, and `e2e` on the merged `main` commit.
- Run the final gate: `npm run release:gate -- MAJOR.MINOR.PATCH`.
- Create an annotated tag only after the final gate succeeds.
- Wait for `verify`, `node20`, and `e2e` on that exact `main` commit.
- Do not edit release-version sources manually. The tag workflow owns the
atomic version update.
- Create an annotated exact SemVer tag such as `v3.1.6`.
- Confirm tag target equals `origin/main`, then push the tag once.
- The workflow must extract the version, update and validate every version
source, commit `chore(release): update versions to vX.Y.Z [skip ci]`, and push
that commit directly to `main` before building release outputs.
- Chrome, Firefox, website, and relay outputs must use the exact prepared commit.
- Monitor the complete release workflow. Distinguish tag push, CI, draft assets,
container publication, attestations, and public GitHub Release status.
- Never call a release complete while any job is pending, failed, or skipped.
The final gate deliberately simulates the release workflow seeing its own
in-progress `preflight` check. This is a permanent regression guard for the
failed first `v3.1.5` release attempt.
- Never reuse, move, or force-push a published tag.
+3 -3
View File
@@ -96,9 +96,9 @@ KoalaSync uses a **single source of truth** for all protocol constants in `share
## Version Numbers
> [!CAUTION]
> **Never edit release versions independently.** Release maintainers run
> `npm run prepare:release -- MAJOR.MINOR.PATCH` on a branch and merge the
> resulting version changes through a CI-green pull request before tagging.
> **Never edit release versions independently.** An annotated exact SemVer tag
> triggers the release workflow, which updates every version source atomically,
> validates the result, and pushes its generated version commit to `main`.
---
+16 -11
View File
@@ -118,23 +118,28 @@ Before starting any task, committing, or pushing, you **MUST** run `git pull --r
> [!CAUTION]
> **AI AGENTS MUST FOLLOW THIS EXACT SEQUENCE WHEN RELEASING A NEW VERSION OR TAGGING.**
>
> **🚫 NO INDEPENDENT VERSION EDITS**: Run `npm run prepare:release -- MAJOR.MINOR.PATCH` on a release-preparation branch. Never edit only one version source, and never expect the tag workflow to modify `main`.
> **🚫 NO INDEPENDENT VERSION EDITS**: Never edit only one version source. The
> annotated SemVer tag workflow extracts the version, updates every source,
> validates them, and pushes its generated `[skip ci]` version commit directly
> to `main`.
> - **Website Versioning**: **NEVER** manually modify generated version strings in `website/www/`. The website build injects version data from `website/version.json` into generated output.
1. **MANDATORY SYNTAX & LINT CHECKS**: Before staging, committing, or pushing any changes, you **MUST** run both checks on every modified JavaScript file:
- **Syntax Validation**: Run `node -c` on every single modified JavaScript file (e.g., `node -c extension/background.js` and `node -c extension/content.js`). **NEVER** commit or push code that fails this check.
- **ESLint Validation**: Run `npm run lint` (or `npx eslint .`). The output must show **zero errors and zero warnings**. ESLint is configured to catch undefined variables, unused vars, unreachable code, and other semantic issues. **NEVER** commit or push code that fails this check.
2. Commit the prepared version and release-note changes on a branch, push it,
open a pull request, and wait for required `verify`, `node20`, and `e2e`
checks. Direct pushes to `main` are not part of the release process.
3. After the PR is merged, update local `main` and create an annotated exact
SemVer tag (`git tag -a v1.4.0 -m "Release v1.4.0"`) on the same commit as
`origin/main`.
2. Commit and push the verified product/release-note changes to `main`, then
wait for `verify`, `node20`, and `e2e` on the exact `origin/main` commit.
Markdown-only changes do not require browser or release gates.
3. From a clean, fast-forwarded `main`, create an annotated exact SemVer tag
(`git tag -a v1.4.0 -m "Release v1.4.0"`) on that same commit and push it once.
- **🚫 TAG IMMUTABILITY**: Once a tag is pushed to `origin`, it is **PERMANENT**. You MUST **NEVER** reuse, move, or force-push an existing tag — not even to "fix" a mistake. If a release is missing a fix, increment the version and create a **new** tag (e.g., `v1.7.0``v1.7.1`). Tags are immutable identifiers; moving them breaks CI pipelines, corrupts the release history, and causes unreproducible builds.
- **🚫 WHEN NOT TO TAG**: Do NOT create a release tag for changes that do NOT affect the shipped extension or server artifacts. Website text changes, documentation updates (`.md` files), and landing page content do NOT require a version tag. Tags trigger the full CI pipeline (Docker build, extension packaging, GitHub Release) — running this for a typo fix wastes CI resources and creates meaningless releases. Only tag when extension code (`extension/`), server code (`server/`), or shared protocol constants (`shared/`) have changed.
4. The release workflow validates the unchanged tagged source, creates a draft
release, publishes and verifies the relay image, and makes the release public
only after every gate succeeds.
5. Verify GitHub assets, attestations, GHCR platforms/digest, and health smoke.
4. The release workflow validates the tag, prepares and validates every version
source, pushes the generated version commit to `main`, and builds Chrome,
Firefox, website, and relay outputs from that exact prepared commit.
5. It creates a draft release, verifies archives, AMO output, checksums,
attestations, relay platforms/digest, and health before making the GitHub
Release public.
6. Verify GitHub assets, attestations, GHCR platforms/digest, and health smoke.
### 🚫 Force Push Policy
> [!CAUTION]
+3 -4
View File
@@ -35,10 +35,9 @@ The build script performs the following actions:
The system enforces a strict `protocolVersion` check during the `JOIN_ROOM` handshake.
- The version is defined in `shared/constants.js`.
- If the extension and server versions mismatch, the server will reject the connection with an `Incompatible protocol version` error.
- Never edit release versions independently. Run
`npm run prepare:release -- MAJOR.MINOR.PATCH` on a release-preparation branch;
the release tag is accepted only after that change reaches `main` with all CI
checks passing.
- Never edit release versions independently. The annotated SemVer tag workflow
updates and validates every version source, then pushes its generated version
commit directly to `main` before building the release.
> [!CAUTION]
> **NEVER** edit the files inside `extension/shared/` directly. They will be overwritten the next time the build script is run. Always edit the files in the root `shared/` directory and then run the build script.
+35 -47
View File
@@ -4,35 +4,38 @@ This document describes the deployment and release process for KoalaSync.
## Tag-Based Releases
KoalaSync uses a gated release pipeline triggered by immutable Git tags.
KoalaSync uses an automated release pipeline triggered by immutable annotated
Git tags.
> [!IMPORTANT]
> **DO NOT** edit individual version files or tag an unmerged branch. Run
> `npm run prepare:release -- MAJOR.MINOR.PATCH` on a branch, review all generated
> source changes, then run the exact Linux/AMD64 candidate gate before opening
> or updating the pull request:
> `npm run release:gate -- MAJOR.MINOR.PATCH --candidate`.
> Merge only through a pull request with successful CI.
> **DO NOT** edit individual version files before tagging. The workflow extracts
> the exact SemVer version from the tag and updates every release-version source
> atomically. Markdown-only changes do not require browser or release gates.
### How it Works
When an annotated tag matching exact `vMAJOR.MINOR.PATCH` is pushed, the GitHub
Actions workflow performs these ordered gates:
1. Confirms that the tag is annotated, points exactly at current `origin/main`,
and matches every committed version source.
2. Requires successful `verify`, `node20`, and `e2e` checks for that commit.
3. Re-runs release verification, cross-browser E2E, and an unpublished relay
container smoke test.
4. Builds and locally validates Chrome/Firefox archives, checksums, AMO output,
website output, archive parity, and manifests.
5. Creates an attested **draft** GitHub Release.
6. Publishes the multi-architecture relay image, verifies both platforms,
attestation identity, digest, tag source, and a running health check.
7. Makes the GitHub Release public only after every preceding gate succeeds.
The release workflow never writes to `main` and never derives shell code from a
tag. Version changes must pass normal branch protection first.
1. Validates that the tag is an annotated exact `vMAJOR.MINOR.PATCH` tag, points
at current `origin/main`, and has successful `verify`, `node20`, and `e2e`
checks. Invalid tags are rejected before any write.
2. Extracts the validated version and uses the tagged commit timestamp so
repeated preparation is deterministic.
3. Updates and validates all version sources: `extension/manifest.base.json`,
`shared/constants.js`, `package.json`, root metadata in `package-lock.json`,
`website/version.json`, `website/template.html`, `website/llms.txt`, and both
the README badge and release banner.
4. Creates `chore(release): update versions to vX.Y.Z [skip ci]` and pushes it
directly to `main`. A failed push stops every dependent release job.
5. Checks out that exact prepared commit for full verification, cross-browser
E2E, the unpublished relay health smoke, Chrome/Firefox/AMO/checksum/archive
validation, website output, and the relay container build.
6. Creates an attested **draft** GitHub Release after extension checks pass.
7. Publishes the canonical lowercase image `ghcr.io/shik3i/koalasync`, then
verifies both platforms, attestation identity, digest, tag source, and a
running health check.
8. Makes the GitHub Release public only after every preceding job succeeds.
---
@@ -40,35 +43,20 @@ tag. Version changes must pass normal branch protection first.
To release a new version (e.g., `v2.5.1`), follow these steps:
1. Create a release-preparation branch from current `main` and update every
version source atomically:
```bash
git checkout main
git pull origin main
git checkout -b release/v2.5.1
npm run prepare:release -- 2.5.1
git add <reviewed-release-paths>
git commit -m "release: prepare v2.5.1"
npm run release:gate -- 2.5.1 --candidate
```
2. Push the already committed and candidate-gated changes, open a pull request,
and wait for required `verify`, `node20`, and `e2e` checks.
3. After the PR is merged, fast-forward local `main`, wait for `verify`,
`node20`, and `e2e` on the merge commit, then run the final local gate. It
refuses a dirty tree, a non-`main` branch, a commit different from
`origin/main`, missing/failed required checks, version drift, non-AMD64
Linux browser execution, or an unhealthy relay container:
1. Fast-forward local `main`, confirm a clean tree at exact `origin/main`, and
wait for `verify`, `node20`, and `e2e` on that commit:
```bash
git checkout main
git pull --ff-only origin main
npm run release:gate -- 2.5.1
test -z "$(git status --porcelain=v1)"
test "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)"
```
4. Only after that command succeeds, create an **annotated** tag on the exact
checked commit:
2. Create an **annotated** exact SemVer tag on that commit. Do not run
`prepare:release`; the tag workflow owns version updates:
```bash
git tag -a v2.5.1 -m "Release v2.5.1"
```
5. Verify the tag target, then push it once:
3. Verify the tag target, then push it once:
```bash
test "$(git rev-parse v2.5.1^{commit})" = "$(git rev-parse origin/main)"
git push origin v2.5.1
@@ -77,10 +65,10 @@ To release a new version (e.g., `v2.5.1`), follow these steps:
Never reuse or move a published tag. Monitor every release job and verify both
the public GitHub assets and GHCR digest before calling the release complete.
`npm run verify` or a host-only Playwright run is not a substitute for
`release:gate`. The gate pins the official Playwright image to the exact
lockfile version and forces `linux/amd64`, matching GitHub's Ubuntu runner even
when the developer host is macOS or ARM64.
`npm run release:gate -- MAJOR.MINOR.PATCH [--candidate]` remains an optional
Linux/AMD64 parity diagnostic when release code changes. It prepares the target
version only inside an isolated clone. It is not required for Markdown-only
changes and does not replace the tag workflow's own gates.
The relay registry reference is always the canonical lowercase
`ghcr.io/shik3i/koalasync`. Docker repository names reject uppercase characters;
+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'