From 5fffeadded7ac2864ce9bcebffdb463031d56110 Mon Sep 17 00:00:00 2001 From: KoalaDev <6156589+Shik3i@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:42:24 +0200 Subject: [PATCH] fix: restore automatic tag versioning --- .github/workflows/release.yml | 79 +++++++++++++++++++++++++-- AGENTS.md | 32 ++++++----- CONTRIBUTING.md | 6 +-- docs/AI_INIT.md | 27 ++++++---- docs/SYNC_GUIDE.md | 7 ++- docs/devops.md | 82 ++++++++++++----------------- scripts/README.md | 22 +++++--- scripts/prepare-release.mjs | 31 +++++++---- scripts/prepare-release.test.mjs | 79 ++++++++++++++++++++++++++- scripts/release-local-gate.mjs | 25 ++++++++- scripts/release-local-gate.test.mjs | 28 +++++++++- scripts/release-preflight.mjs | 59 ++++++++++++++++++--- scripts/release-preflight.test.mjs | 2 +- 13 files changed, 361 insertions(+), 118 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dec7707..3422939 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 82d20e3..892bbeb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d3943c5..588f60b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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`. --- diff --git a/docs/AI_INIT.md b/docs/AI_INIT.md index b1a0ae2..c9bdbc6 100644 --- a/docs/AI_INIT.md +++ b/docs/AI_INIT.md @@ -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] diff --git a/docs/SYNC_GUIDE.md b/docs/SYNC_GUIDE.md index 6fec6ac..b6397e5 100644 --- a/docs/SYNC_GUIDE.md +++ b/docs/SYNC_GUIDE.md @@ -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. diff --git a/docs/devops.md b/docs/devops.md index 81ff77a..7c3c363 100644 --- a/docs/devops.md +++ b/docs/devops.md @@ -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 - 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; diff --git a/scripts/README.md b/scripts/README.md index fb8d82e..4ff100f 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -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 diff --git a/scripts/prepare-release.mjs b/scripts/prepare-release.mjs index f3a7176..4272f65 100644 --- a/scripts/prepare-release.mjs +++ b/scripts/prepare-release.mjs @@ -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; diff --git a/scripts/prepare-release.test.mjs b/scripts/prepare-release.test.mjs index c6c0412..a6d649b 100644 --- a/scripts/prepare-release.test.mjs +++ b/scripts/prepare-release.test.mjs @@ -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); + }); }); diff --git a/scripts/release-local-gate.mjs b/scripts/release-local-gate.mjs index e978a93..497fe19 100644 --- a/scripts/release-local-gate.mjs +++ b/scripts/release-local-gate.mjs @@ -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() ]); diff --git a/scripts/release-local-gate.test.mjs b/scripts/release-local-gate.test.mjs index 82836b5..99c1672 100644 --- a/scripts/release-local-gate.test.mjs +++ b/scripts/release-local-gate.test.mjs @@ -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', diff --git a/scripts/release-preflight.mjs b/scripts/release-preflight.mjs index fbc00c2..c82fe57 100644 --- a/scripts/release-preflight.mjs +++ b/scripts/release-preflight.mjs @@ -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}`); } diff --git a/scripts/release-preflight.test.mjs b/scripts/release-preflight.test.mjs index 788dbb7..991078d 100644 --- a/scripts/release-preflight.test.mjs +++ b/scripts/release-preflight.test.mjs @@ -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'