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