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: outputs:
version: ${{ steps.release-ref.outputs.version }} version: ${{ steps.release-ref.outputs.version }}
tag-commit: ${{ steps.release-ref.outputs.tag_commit }} tag-commit: ${{ steps.release-ref.outputs.tag_commit }}
release-timestamp: ${{ steps.release-ref.outputs.release_timestamp }}
steps: steps:
- name: Checkout release tag - name: Checkout release tag
uses: actions/checkout@v7 uses: actions/checkout@v7
@@ -39,6 +40,71 @@ jobs:
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 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 - name: Install root dependencies
run: npm ci run: npm ci
@@ -73,16 +139,17 @@ jobs:
exit 1 exit 1
release-extension-draft: release-extension-draft:
needs: preflight needs: [preflight, prepare-release, verify-prepared-release]
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: write contents: write
id-token: write id-token: write
attestations: write attestations: write
steps: steps:
- name: Checkout release tag - name: Checkout prepared release commit
uses: actions/checkout@v7 uses: actions/checkout@v7
with: with:
ref: ${{ needs.prepare-release.outputs.prepared-commit }}
fetch-depth: 0 fetch-depth: 0
- name: Set up Node.js - name: Set up Node.js
@@ -137,7 +204,7 @@ jobs:
if-no-files-found: error if-no-files-found: error
release-server: release-server:
needs: [preflight, release-extension-draft] needs: [preflight, prepare-release, verify-prepared-release, release-extension-draft]
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
IMAGE: ghcr.io/shik3i/koalasync IMAGE: ghcr.io/shik3i/koalasync
@@ -147,8 +214,10 @@ jobs:
id-token: write id-token: write
attestations: write attestations: write
steps: steps:
- name: Checkout release tag - name: Checkout prepared release commit
uses: actions/checkout@v7 uses: actions/checkout@v7
with:
ref: ${{ needs.prepare-release.outputs.prepared-commit }}
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@v4
@@ -212,7 +281,7 @@ jobs:
exit 1 exit 1
finalize-release: finalize-release:
needs: [preflight, release-extension-draft, release-server] needs: [prepare-release, verify-prepared-release, release-extension-draft, release-server]
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: write 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. - Inspect branch, remote tracking, dirty state, and relevant release workflow.
- Preserve unrelated work and stage only reviewed paths. - 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. - Never treat a host macOS browser run as GitHub Linux parity.
- On a release branch, commit all intended changes and run: - Run checks proportionate to the changed files. Markdown-only changes do not
`npm run release:gate -- MAJOR.MINOR.PATCH --candidate` require browser E2E or `release:gate`.
- This command must use the official lockfile-matched Playwright image with - `npm run release:gate -- MAJOR.MINOR.PATCH [--candidate]` remains available
`linux/amd64`, Ubuntu Noble, and `CI=1`; it must run clean installs, full when release code itself needs Linux/AMD64 parity validation. It prepares the
verification, all browser E2E tests, the relay image build, and health smoke. requested version only inside its isolated clone.
- A failed, interrupted, ARM64, skipped, or partial run is not a passing gate.
- All OCI image references must use the canonical lowercase - All OCI image references must use the canonical lowercase
`ghcr.io/shik3i/koalasync`; never construct Docker references from the `ghcr.io/shik3i/koalasync`; never construct Docker references from the
case-preserving `${{ github.repository }}` value. The local gate enforces this. 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 ## 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`. - Fast-forward local `main` and confirm a clean tree at exact `origin/main`.
- Wait for `verify`, `node20`, and `e2e` on the merged `main` commit. - Wait for `verify`, `node20`, and `e2e` on that exact `main` commit.
- Run the final gate: `npm run release:gate -- MAJOR.MINOR.PATCH`. - Do not edit release-version sources manually. The tag workflow owns the
- Create an annotated tag only after the final gate succeeds. 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. - 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, - Monitor the complete release workflow. Distinguish tag push, CI, draft assets,
container publication, attestations, and public GitHub Release status. container publication, attestations, and public GitHub Release status.
- Never call a release complete while any job is pending, failed, or skipped. - Never call a release complete while any job is pending, failed, or skipped.
- Never reuse, move, or force-push a published tag.
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.
+3 -3
View File
@@ -96,9 +96,9 @@ KoalaSync uses a **single source of truth** for all protocol constants in `share
## Version Numbers ## Version Numbers
> [!CAUTION] > [!CAUTION]
> **Never edit release versions independently.** Release maintainers run > **Never edit release versions independently.** An annotated exact SemVer tag
> `npm run prepare:release -- MAJOR.MINOR.PATCH` on a branch and merge the > triggers the release workflow, which updates every version source atomically,
> resulting version changes through a CI-green pull request before tagging. > 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] > [!CAUTION]
> **AI AGENTS MUST FOLLOW THIS EXACT SEQUENCE WHEN RELEASING A NEW VERSION OR TAGGING.** > **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. > - **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: 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. - **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. - **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, 2. Commit and push the verified product/release-note changes to `main`, then
open a pull request, and wait for required `verify`, `node20`, and `e2e` wait for `verify`, `node20`, and `e2e` on the exact `origin/main` commit.
checks. Direct pushes to `main` are not part of the release process. Markdown-only changes do not require browser or release gates.
3. After the PR is merged, update local `main` and create an annotated exact 3. From a clean, fast-forwarded `main`, create an annotated exact SemVer tag
SemVer tag (`git tag -a v1.4.0 -m "Release v1.4.0"`) on the same commit as (`git tag -a v1.4.0 -m "Release v1.4.0"`) on that same commit and push it once.
`origin/main`.
- **🚫 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. - **🚫 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. - **🚫 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 4. The release workflow validates the tag, prepares and validates every version
release, publishes and verifies the relay image, and makes the release public source, pushes the generated version commit to `main`, and builds Chrome,
only after every gate succeeds. Firefox, website, and relay outputs from that exact prepared commit.
5. Verify GitHub assets, attestations, GHCR platforms/digest, and health smoke. 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 ### 🚫 Force Push Policy
> [!CAUTION] > [!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 system enforces a strict `protocolVersion` check during the `JOIN_ROOM` handshake.
- The version is defined in `shared/constants.js`. - 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. - 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 - Never edit release versions independently. The annotated SemVer tag workflow
`npm run prepare:release -- MAJOR.MINOR.PATCH` on a release-preparation branch; updates and validates every version source, then pushes its generated version
the release tag is accepted only after that change reaches `main` with all CI commit directly to `main` before building the release.
checks passing.
> [!CAUTION] > [!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. > **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 ## 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] > [!IMPORTANT]
> **DO NOT** edit individual version files or tag an unmerged branch. Run > **DO NOT** edit individual version files before tagging. The workflow extracts
> `npm run prepare:release -- MAJOR.MINOR.PATCH` on a branch, review all generated > the exact SemVer version from the tag and updates every release-version source
> source changes, then run the exact Linux/AMD64 candidate gate before opening > atomically. Markdown-only changes do not require browser or release gates.
> or updating the pull request:
> `npm run release:gate -- MAJOR.MINOR.PATCH --candidate`.
> Merge only through a pull request with successful CI.
### How it Works ### How it Works
When an annotated tag matching exact `vMAJOR.MINOR.PATCH` is pushed, the GitHub When an annotated tag matching exact `vMAJOR.MINOR.PATCH` is pushed, the GitHub
Actions workflow performs these ordered gates: Actions workflow performs these ordered gates:
1. Confirms that the tag is annotated, points exactly at current `origin/main`, 1. Validates that the tag is an annotated exact `vMAJOR.MINOR.PATCH` tag, points
and matches every committed version source. at current `origin/main`, and has successful `verify`, `node20`, and `e2e`
2. Requires successful `verify`, `node20`, and `e2e` checks for that commit. checks. Invalid tags are rejected before any write.
3. Re-runs release verification, cross-browser E2E, and an unpublished relay 2. Extracts the validated version and uses the tagged commit timestamp so
container smoke test. repeated preparation is deterministic.
4. Builds and locally validates Chrome/Firefox archives, checksums, AMO output, 3. Updates and validates all version sources: `extension/manifest.base.json`,
website output, archive parity, and manifests. `shared/constants.js`, `package.json`, root metadata in `package-lock.json`,
5. Creates an attested **draft** GitHub Release. `website/version.json`, `website/template.html`, `website/llms.txt`, and both
6. Publishes the multi-architecture relay image, verifies both platforms, the README badge and release banner.
attestation identity, digest, tag source, and a running health check. 4. Creates `chore(release): update versions to vX.Y.Z [skip ci]` and pushes it
7. Makes the GitHub Release public only after every preceding gate succeeds. directly to `main`. A failed push stops every dependent release job.
5. Checks out that exact prepared commit for full verification, cross-browser
The release workflow never writes to `main` and never derives shell code from a E2E, the unpublished relay health smoke, Chrome/Firefox/AMO/checksum/archive
tag. Version changes must pass normal branch protection first. 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: 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 1. Fast-forward local `main`, confirm a clean tree at exact `origin/main`, and
version source atomically: wait for `verify`, `node20`, and `e2e` on that commit:
```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:
```bash ```bash
git checkout main git checkout main
git pull --ff-only origin 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 2. Create an **annotated** exact SemVer tag on that commit. Do not run
checked commit: `prepare:release`; the tag workflow owns version updates:
```bash ```bash
git tag -a v2.5.1 -m "Release v2.5.1" 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 ```bash
test "$(git rev-parse v2.5.1^{commit})" = "$(git rev-parse origin/main)" test "$(git rev-parse v2.5.1^{commit})" = "$(git rev-parse origin/main)"
git push origin v2.5.1 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 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. 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 `npm run release:gate -- MAJOR.MINOR.PATCH [--candidate]` remains an optional
`release:gate`. The gate pins the official Playwright image to the exact Linux/AMD64 parity diagnostic when release code changes. It prepares the target
lockfile version and forces `linux/amd64`, matching GitHub's Ubuntu runner even version only inside an isolated clone. It is not required for Markdown-only
when the developer host is macOS or ARM64. changes and does not replace the tag workflow's own gates.
The relay registry reference is always the canonical lowercase The relay registry reference is always the canonical lowercase
`ghcr.io/shik3i/koalasync`. Docker repository names reject uppercase characters; `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 lint
npm run test:unit npm run test:unit
npm run test:coverage npm run test:coverage
npm run prepare:release -- 3.1.5 npm run release:gate -- 3.1.6 --candidate
npm run release:gate -- 3.1.5 --candidate
``` ```
- `npm run build:extension` runs `scripts/build-extension.cjs`. - `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 lint` runs ESLint across the repository.
- `npm run test:unit` runs Vitest tests. - `npm run test:unit` runs Vitest tests.
- `npm run test:coverage` runs the same tests with the enforced coverage floor. - `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. - `scripts/prepare-release.mjs` is an internal tag-workflow helper. It updates
- `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. 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 ## build-extension.cjs
@@ -100,10 +105,11 @@ integration gate. New unclassified files fail `npm run verify`.
## Published Release Verification ## Published Release Verification
Before publication, the release workflow validates the exact annotated SemVer Before publication, the release workflow validates the exact annotated SemVer
tag, requires it to point at current `origin/main`, requires successful tag and required checks, prepares and validates every version source, pushes
`verify`, `node20`, and `e2e` checks, and runs the complete gates again. It then the generated version commit directly to `main`, and checks out that exact
creates a draft release, publishes and smoke-tests the relay image, and only commit for all verification and builds. It then creates a draft release,
afterwards makes the GitHub Release public. The published-asset gate runs: publishes and smoke-tests the relay image, and only afterwards makes the GitHub
Release public. The published-asset gate runs:
```bash ```bash
node scripts/verify-published-release.mjs vMAJOR.MINOR.PATCH --repo Shik3i/KoalaSync 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); return text.replace(pattern, replacement);
} }
function writeJson(relativePath, update) { function writeJson(root, relativePath, update) {
const absolutePath = path.join(repoRoot, relativePath); const absolutePath = path.join(root, relativePath);
const value = JSON.parse(fs.readFileSync(absolutePath, 'utf8')); const value = JSON.parse(fs.readFileSync(absolutePath, 'utf8'));
update(value); update(value);
fs.writeFileSync(absolutePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); fs.writeFileSync(absolutePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
} }
function updateText(relativePath, pattern, replacement, label) { function updateText(root, relativePath, pattern, replacement, label) {
const absolutePath = path.join(repoRoot, relativePath); const absolutePath = path.join(root, relativePath);
const current = fs.readFileSync(absolutePath, 'utf8'); const current = fs.readFileSync(absolutePath, 'utf8');
fs.writeFileSync(absolutePath, replaceExactly(current, pattern, replacement, label), '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}`); versionFromTag(`v${version}`);
const timestamp = date.toISOString().replace(/\.\d{3}Z$/u, 'Z'); const timestamp = date.toISOString().replace(/\.\d{3}Z$/u, 'Z');
writeJson('package.json', value => { value.version = version; }); writeJson(root, 'package.json', value => { value.version = version; });
writeJson('package-lock.json', value => { writeJson(root, 'package-lock.json', value => {
value.version = version; value.version = version;
value.packages[''].version = version; value.packages[''].version = version;
}); });
writeJson('extension/manifest.base.json', value => { value.version = version; }); writeJson(root, 'extension/manifest.base.json', value => { value.version = version; });
writeJson('website/version.json', value => { writeJson(root, 'website/version.json', value => {
value.version = version; value.version = version;
value.date = timestamp; value.date = timestamp;
}); });
updateText( updateText(
root,
'shared/constants.js', 'shared/constants.js',
/export const APP_VERSION = ["'][^"']+["'];/gu, /export const APP_VERSION = ["'][^"']+["'];/gu,
`export const APP_VERSION = "${version}";`, `export const APP_VERSION = "${version}";`,
'shared/constants.js' 'shared/constants.js'
); );
updateText( updateText(
root,
'website/template.html', 'website/template.html',
/"softwareVersion": "[^"]+"/gu, /"softwareVersion": "[^"]+"/gu,
`"softwareVersion": "${version}"`, `"softwareVersion": "${version}"`,
'website/template.html' 'website/template.html'
); );
updateText( updateText(
root,
'website/llms.txt', 'website/llms.txt',
/Current website release: .+/gu, /Current website release: .+/gu,
`Current website release: ${version}`, `Current website release: ${version}`,
'website/llms.txt' 'website/llms.txt'
); );
updateText( updateText(
root,
'README.md', 'README.md',
/Release-v\d+\.\d+\.\d+-blue/gu, /Release-v\d+\.\d+\.\d+-blue/gu,
`Release-v${version}-blue`, `Release-v${version}-blue`,
'README.md release badge' 'README.md release badge'
); );
updateText( updateText(
root,
'README.md', 'README.md',
/New v\d+\.\d+\.\d+ Release!/gu, /New v\d+\.\d+\.\d+ Release!/gu,
`New v${version} Release!`, `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]; const isMainModule = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
if (isMainModule) { if (isMainModule) {
try { try {
if (process.argv.length !== 3) throw new Error('Usage: npm run prepare:release -- MAJOR.MINOR.PATCH'); if (process.argv.length < 3 || process.argv.length > 4) {
prepareRelease(process.argv[2]); 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) { } catch (error) {
console.error(`Release preparation failed: ${error.message}`); console.error(`Release preparation failed: ${error.message}`);
process.exitCode = 1; process.exitCode = 1;
+77 -2
View File
@@ -1,5 +1,47 @@
import { describe, expect, it } from 'vitest'; import fs from 'node:fs';
import { replaceExactly } from './prepare-release.mjs'; 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', () => { describe('release preparation helpers', () => {
it('replaces one and only one version marker', () => { 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')) expect(() => replaceExactly('version=1 version=2', /version=\d+/gu, 'version=3', 'fixture'))
.toThrow('fixture must contain exactly one release-version marker'); .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 { versionFromTag } from './release-artifact-checks.mjs';
import { import {
parseCheckRuns, parseCheckRuns,
validateReleaseSourceVersion,
validateRequiredChecks validateRequiredChecks
} from './release-preflight.mjs'; } from './release-preflight.mjs';
@@ -51,6 +50,8 @@ export function linuxGateCommand() {
return [ return [
'git clone --no-local /src /work', 'git clone --no-local /src /work',
'cd /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',
'npm ci --prefix server', 'npm ci --prefix server',
'npm run verify', 'npm run verify',
@@ -76,6 +77,26 @@ export function validateReleaseWorkflowContract(text) {
if (/ghcr\.io\/\$\{\{\s*github\.repository\s*\}\}/u.test(workflow)) { 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'); 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; return image;
} }
@@ -134,7 +155,6 @@ async function smokeRelayImage(image) {
export async function runReleaseGate({ version, candidate }) { export async function runReleaseGate({ version, candidate }) {
assertCleanTree(); assertCleanTree();
validateReleaseSourceVersion(version, repoRoot);
validateReleaseWorkflowContract(fs.readFileSync( validateReleaseWorkflowContract(fs.readFileSync(
path.join(repoRoot, '.github/workflows/release.yml'), 'utf8' 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', ['pull', '--platform', 'linux/amd64', playwrightImage]);
run('docker', [ run('docker', [
'run', '--rm', '--platform', 'linux/amd64', '--ipc=host', '--env', 'CI=1', 'run', '--rm', '--platform', 'linux/amd64', '--ipc=host', '--env', 'CI=1',
'--env', `RELEASE_VERSION=${version}`,
'--volume', `${repoRoot}:/src:ro`, playwrightImage, '--volume', `${repoRoot}:/src:ro`, playwrightImage,
'bash', '-lc', linuxGateCommand() 'bash', '-lc', linuxGateCommand()
]); ]);
+27 -1
View File
@@ -1,3 +1,4 @@
import fs from 'node:fs';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
linuxGateCommand, linuxGateCommand,
@@ -33,7 +34,17 @@ describe('local release gate contract', () => {
const valid = [ const valid = [
'IMAGE: ghcr.io/shik3i/koalasync', 'IMAGE: ghcr.io/shik3i/koalasync',
'images: ${{ env.IMAGE }}', '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'); ].join('\n');
expect(validateReleaseWorkflowContract(valid)).toBe('ghcr.io/shik3i/koalasync'); expect(validateReleaseWorkflowContract(valid)).toBe('ghcr.io/shik3i/koalasync');
expect(() => validateReleaseWorkflowContract(valid.replace( expect(() => validateReleaseWorkflowContract(valid.replace(
@@ -44,10 +55,25 @@ describe('local release gate contract', () => {
.toThrow('case-preserving github.repository'); .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', () => { it('runs the complete CI-equivalent dependency, verify, and browser sequence', () => {
expect(linuxGateCommand()).toBe([ expect(linuxGateCommand()).toBe([
'git clone --no-local /src /work', 'git clone --no-local /src /work',
'cd /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',
'npm ci --prefix server', 'npm ci --prefix server',
'npm run verify', '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()) { export function validateReleaseSourceVersion(expectedVersion, repoRoot = process.cwd()) {
const readJson = relativePath => JSON.parse(fs.readFileSync(path.join(repoRoot, relativePath), 'utf8')); const readJson = relativePath => JSON.parse(fs.readFileSync(path.join(repoRoot, relativePath), 'utf8'));
const packageJson = readJson('package.json'); const packageJson = readJson('package.json');
@@ -58,21 +66,46 @@ export function validateReleaseSourceVersion(expectedVersion, repoRoot = process
const manifest = readJson('extension/manifest.base.json'); const manifest = readJson('extension/manifest.base.json');
const websiteVersion = readJson('website/version.json'); const websiteVersion = readJson('website/version.json');
const constants = fs.readFileSync(path.join(repoRoot, 'shared/constants.js'), 'utf8'); 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, { validateVersionSnapshot(expectedVersion, {
'package.json': packageJson.version, 'package.json': packageJson.version,
'package-lock.json': packageLock.version, 'package-lock.json': packageLock.version,
'package-lock root package': packageLock.packages?.['']?.version, 'package-lock root package': packageLock.packages?.['']?.version,
'extension manifest': manifest.version, 'extension manifest': manifest.version,
'shared constants': appVersion, 'shared constants': versionFromMarker(
'website/version.json': websiteVersion.version 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 }) { export function verifyReleaseRef({ tag, repo }) {
const version = versionFromTag(tag); const version = versionFromTag(tag);
validateRepositoryName(repo); validateRepositoryName(repo);
validateReleaseSourceVersion(version);
const tagRef = `refs/tags/${tag}`; const tagRef = `refs/tags/${tag}`;
if (run('git', ['cat-file', '-t', tagRef]) !== 'tag') { if (run('git', ['cat-file', '-t', tagRef]) !== 'tag') {
throw new Error(`${tag} must be an annotated 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' '--jq', '.check_runs[] | [.name, .conclusion, .html_url] | @tsv'
])); ]));
validateRequiredChecks(checks); validateRequiredChecks(checks);
return { version, tagCommit }; const releaseTimestamp = run('git', ['show', '-s', '--format=%cI', tagCommit]);
return { version, tagCommit, releaseTimestamp };
} }
function main() { 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 tag = process.env.GITHUB_REF_NAME || '';
const repo = process.env.GITHUB_REPOSITORY || ''; const repo = process.env.GITHUB_REPOSITORY || '';
const outputPath = process.env.GITHUB_OUTPUT || ''; const outputPath = process.env.GITHUB_OUTPUT || '';
const result = verifyReleaseRef({ tag, repo }); const result = verifyReleaseRef({ tag, repo });
if (!outputPath) throw new Error('GITHUB_OUTPUT is required'); 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}`); 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'); 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', { expect(() => validateVersionSnapshot('3.1.5', {
package: '3.1.5', package: '3.1.5',
manifest: '3.1.5' manifest: '3.1.5'