mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-08-29 20:17:08 +00:00
fix: restore automatic tag versioning
This commit is contained in:
@@ -18,6 +18,7 @@ jobs:
|
||||
outputs:
|
||||
version: ${{ steps.release-ref.outputs.version }}
|
||||
tag-commit: ${{ steps.release-ref.outputs.tag_commit }}
|
||||
release-timestamp: ${{ steps.release-ref.outputs.release_timestamp }}
|
||||
steps:
|
||||
- name: Checkout release tag
|
||||
uses: actions/checkout@v7
|
||||
@@ -39,6 +40,71 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
prepare-release:
|
||||
needs: preflight
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
outputs:
|
||||
prepared-commit: ${{ steps.version-commit.outputs.prepared_commit }}
|
||||
steps:
|
||||
- name: Checkout release tag
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Prepare and validate every release-version source
|
||||
env:
|
||||
VERSION: ${{ needs.preflight.outputs.version }}
|
||||
RELEASE_TIMESTAMP: ${{ needs.preflight.outputs.release-timestamp }}
|
||||
run: |
|
||||
node scripts/prepare-release.mjs "$VERSION" "$RELEASE_TIMESTAMP"
|
||||
node scripts/release-preflight.mjs --sources "$VERSION"
|
||||
|
||||
- name: Commit and push prepared release to main
|
||||
id: version-commit
|
||||
env:
|
||||
VERSION: ${{ needs.preflight.outputs.version }}
|
||||
run: |
|
||||
git config --local user.email "action@github.com"
|
||||
git config --local user.name "GitHub Action"
|
||||
git add extension/manifest.base.json shared/constants.js package.json package-lock.json website/version.json website/template.html website/llms.txt README.md
|
||||
if git diff --cached --quiet; then
|
||||
echo "Release versions already match v$VERSION"
|
||||
else
|
||||
git commit -m "chore(release): update versions to v$VERSION [skip ci]"
|
||||
fi
|
||||
git push origin HEAD:main
|
||||
echo "prepared_commit=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
verify-prepared-release:
|
||||
needs: [preflight, prepare-release]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout prepared release commit
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ needs.prepare-release.outputs.prepared-commit }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: |
|
||||
package-lock.json
|
||||
server/package-lock.json
|
||||
|
||||
- name: Validate every prepared version source
|
||||
env:
|
||||
VERSION: ${{ needs.preflight.outputs.version }}
|
||||
run: node scripts/release-preflight.mjs --sources "$VERSION"
|
||||
|
||||
- name: Install root dependencies
|
||||
run: npm ci
|
||||
|
||||
@@ -73,16 +139,17 @@ jobs:
|
||||
exit 1
|
||||
|
||||
release-extension-draft:
|
||||
needs: preflight
|
||||
needs: [preflight, prepare-release, verify-prepared-release]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
attestations: write
|
||||
steps:
|
||||
- name: Checkout release tag
|
||||
- name: Checkout prepared release commit
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ needs.prepare-release.outputs.prepared-commit }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Node.js
|
||||
@@ -137,7 +204,7 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
release-server:
|
||||
needs: [preflight, release-extension-draft]
|
||||
needs: [preflight, prepare-release, verify-prepared-release, release-extension-draft]
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
IMAGE: ghcr.io/shik3i/koalasync
|
||||
@@ -147,8 +214,10 @@ jobs:
|
||||
id-token: write
|
||||
attestations: write
|
||||
steps:
|
||||
- name: Checkout release tag
|
||||
- name: Checkout prepared release commit
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ needs.prepare-release.outputs.prepared-commit }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
@@ -212,7 +281,7 @@ jobs:
|
||||
exit 1
|
||||
|
||||
finalize-release:
|
||||
needs: [preflight, release-extension-draft, release-server]
|
||||
needs: [prepare-release, verify-prepared-release, release-extension-draft, release-server]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -8,33 +8,31 @@ These rules are mandatory for every automated agent working in this repository.
|
||||
- Inspect branch, remote tracking, dirty state, and relevant release workflow.
|
||||
- Preserve unrelated work and stage only reviewed paths.
|
||||
|
||||
## Before claiming a release is ready
|
||||
## Release verification
|
||||
|
||||
- Never treat a host macOS browser run as GitHub Linux parity.
|
||||
- On a release branch, commit all intended changes and run:
|
||||
`npm run release:gate -- MAJOR.MINOR.PATCH --candidate`
|
||||
- This command must use the official lockfile-matched Playwright image with
|
||||
`linux/amd64`, Ubuntu Noble, and `CI=1`; it must run clean installs, full
|
||||
verification, all browser E2E tests, the relay image build, and health smoke.
|
||||
- A failed, interrupted, ARM64, skipped, or partial run is not a passing gate.
|
||||
- Run checks proportionate to the changed files. Markdown-only changes do not
|
||||
require browser E2E or `release:gate`.
|
||||
- `npm run release:gate -- MAJOR.MINOR.PATCH [--candidate]` remains available
|
||||
when release code itself needs Linux/AMD64 parity validation. It prepares the
|
||||
requested version only inside its isolated clone.
|
||||
- All OCI image references must use the canonical lowercase
|
||||
`ghcr.io/shik3i/koalasync`; never construct Docker references from the
|
||||
case-preserving `${{ github.repository }}` value. The local gate enforces this.
|
||||
- Do not say "release-ready" until the candidate gate and PR required checks
|
||||
are green for the exact commit.
|
||||
|
||||
## Before pushing a release tag
|
||||
|
||||
- Merge through a PR; resolve every review thread.
|
||||
- Fast-forward local `main` and confirm a clean tree at exact `origin/main`.
|
||||
- Wait for `verify`, `node20`, and `e2e` on the merged `main` commit.
|
||||
- Run the final gate: `npm run release:gate -- MAJOR.MINOR.PATCH`.
|
||||
- Create an annotated tag only after the final gate succeeds.
|
||||
- Wait for `verify`, `node20`, and `e2e` on that exact `main` commit.
|
||||
- Do not edit release-version sources manually. The tag workflow owns the
|
||||
atomic version update.
|
||||
- Create an annotated exact SemVer tag such as `v3.1.6`.
|
||||
- Confirm tag target equals `origin/main`, then push the tag once.
|
||||
- The workflow must extract the version, update and validate every version
|
||||
source, commit `chore(release): update versions to vX.Y.Z [skip ci]`, and push
|
||||
that commit directly to `main` before building release outputs.
|
||||
- Chrome, Firefox, website, and relay outputs must use the exact prepared commit.
|
||||
- Monitor the complete release workflow. Distinguish tag push, CI, draft assets,
|
||||
container publication, attestations, and public GitHub Release status.
|
||||
- Never call a release complete while any job is pending, failed, or skipped.
|
||||
|
||||
The final gate deliberately simulates the release workflow seeing its own
|
||||
in-progress `preflight` check. This is a permanent regression guard for the
|
||||
failed first `v3.1.5` release attempt.
|
||||
- Never reuse, move, or force-push a published tag.
|
||||
|
||||
+3
-3
@@ -96,9 +96,9 @@ KoalaSync uses a **single source of truth** for all protocol constants in `share
|
||||
## Version Numbers
|
||||
|
||||
> [!CAUTION]
|
||||
> **Never edit release versions independently.** Release maintainers run
|
||||
> `npm run prepare:release -- MAJOR.MINOR.PATCH` on a branch and merge the
|
||||
> resulting version changes through a CI-green pull request before tagging.
|
||||
> **Never edit release versions independently.** An annotated exact SemVer tag
|
||||
> triggers the release workflow, which updates every version source atomically,
|
||||
> validates the result, and pushes its generated version commit to `main`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+16
-11
@@ -118,23 +118,28 @@ Before starting any task, committing, or pushing, you **MUST** run `git pull --r
|
||||
> [!CAUTION]
|
||||
> **AI AGENTS MUST FOLLOW THIS EXACT SEQUENCE WHEN RELEASING A NEW VERSION OR TAGGING.**
|
||||
>
|
||||
> **🚫 NO INDEPENDENT VERSION EDITS**: Run `npm run prepare:release -- MAJOR.MINOR.PATCH` on a release-preparation branch. Never edit only one version source, and never expect the tag workflow to modify `main`.
|
||||
> **🚫 NO INDEPENDENT VERSION EDITS**: Never edit only one version source. The
|
||||
> annotated SemVer tag workflow extracts the version, updates every source,
|
||||
> validates them, and pushes its generated `[skip ci]` version commit directly
|
||||
> to `main`.
|
||||
> - **Website Versioning**: **NEVER** manually modify generated version strings in `website/www/`. The website build injects version data from `website/version.json` into generated output.
|
||||
1. **MANDATORY SYNTAX & LINT CHECKS**: Before staging, committing, or pushing any changes, you **MUST** run both checks on every modified JavaScript file:
|
||||
- **Syntax Validation**: Run `node -c` on every single modified JavaScript file (e.g., `node -c extension/background.js` and `node -c extension/content.js`). **NEVER** commit or push code that fails this check.
|
||||
- **ESLint Validation**: Run `npm run lint` (or `npx eslint .`). The output must show **zero errors and zero warnings**. ESLint is configured to catch undefined variables, unused vars, unreachable code, and other semantic issues. **NEVER** commit or push code that fails this check.
|
||||
2. Commit the prepared version and release-note changes on a branch, push it,
|
||||
open a pull request, and wait for required `verify`, `node20`, and `e2e`
|
||||
checks. Direct pushes to `main` are not part of the release process.
|
||||
3. After the PR is merged, update local `main` and create an annotated exact
|
||||
SemVer tag (`git tag -a v1.4.0 -m "Release v1.4.0"`) on the same commit as
|
||||
`origin/main`.
|
||||
2. Commit and push the verified product/release-note changes to `main`, then
|
||||
wait for `verify`, `node20`, and `e2e` on the exact `origin/main` commit.
|
||||
Markdown-only changes do not require browser or release gates.
|
||||
3. From a clean, fast-forwarded `main`, create an annotated exact SemVer tag
|
||||
(`git tag -a v1.4.0 -m "Release v1.4.0"`) on that same commit and push it once.
|
||||
- **🚫 TAG IMMUTABILITY**: Once a tag is pushed to `origin`, it is **PERMANENT**. You MUST **NEVER** reuse, move, or force-push an existing tag — not even to "fix" a mistake. If a release is missing a fix, increment the version and create a **new** tag (e.g., `v1.7.0` → `v1.7.1`). Tags are immutable identifiers; moving them breaks CI pipelines, corrupts the release history, and causes unreproducible builds.
|
||||
- **🚫 WHEN NOT TO TAG**: Do NOT create a release tag for changes that do NOT affect the shipped extension or server artifacts. Website text changes, documentation updates (`.md` files), and landing page content do NOT require a version tag. Tags trigger the full CI pipeline (Docker build, extension packaging, GitHub Release) — running this for a typo fix wastes CI resources and creates meaningless releases. Only tag when extension code (`extension/`), server code (`server/`), or shared protocol constants (`shared/`) have changed.
|
||||
4. The release workflow validates the unchanged tagged source, creates a draft
|
||||
release, publishes and verifies the relay image, and makes the release public
|
||||
only after every gate succeeds.
|
||||
5. Verify GitHub assets, attestations, GHCR platforms/digest, and health smoke.
|
||||
4. The release workflow validates the tag, prepares and validates every version
|
||||
source, pushes the generated version commit to `main`, and builds Chrome,
|
||||
Firefox, website, and relay outputs from that exact prepared commit.
|
||||
5. It creates a draft release, verifies archives, AMO output, checksums,
|
||||
attestations, relay platforms/digest, and health before making the GitHub
|
||||
Release public.
|
||||
6. Verify GitHub assets, attestations, GHCR platforms/digest, and health smoke.
|
||||
|
||||
### 🚫 Force Push Policy
|
||||
> [!CAUTION]
|
||||
|
||||
+3
-4
@@ -35,10 +35,9 @@ The build script performs the following actions:
|
||||
The system enforces a strict `protocolVersion` check during the `JOIN_ROOM` handshake.
|
||||
- The version is defined in `shared/constants.js`.
|
||||
- If the extension and server versions mismatch, the server will reject the connection with an `Incompatible protocol version` error.
|
||||
- Never edit release versions independently. Run
|
||||
`npm run prepare:release -- MAJOR.MINOR.PATCH` on a release-preparation branch;
|
||||
the release tag is accepted only after that change reaches `main` with all CI
|
||||
checks passing.
|
||||
- Never edit release versions independently. The annotated SemVer tag workflow
|
||||
updates and validates every version source, then pushes its generated version
|
||||
commit directly to `main` before building the release.
|
||||
|
||||
> [!CAUTION]
|
||||
> **NEVER** edit the files inside `extension/shared/` directly. They will be overwritten the next time the build script is run. Always edit the files in the root `shared/` directory and then run the build script.
|
||||
|
||||
+35
-47
@@ -4,35 +4,38 @@ This document describes the deployment and release process for KoalaSync.
|
||||
|
||||
## Tag-Based Releases
|
||||
|
||||
KoalaSync uses a gated release pipeline triggered by immutable Git tags.
|
||||
KoalaSync uses an automated release pipeline triggered by immutable annotated
|
||||
Git tags.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **DO NOT** edit individual version files or tag an unmerged branch. Run
|
||||
> `npm run prepare:release -- MAJOR.MINOR.PATCH` on a branch, review all generated
|
||||
> source changes, then run the exact Linux/AMD64 candidate gate before opening
|
||||
> or updating the pull request:
|
||||
> `npm run release:gate -- MAJOR.MINOR.PATCH --candidate`.
|
||||
> Merge only through a pull request with successful CI.
|
||||
> **DO NOT** edit individual version files before tagging. The workflow extracts
|
||||
> the exact SemVer version from the tag and updates every release-version source
|
||||
> atomically. Markdown-only changes do not require browser or release gates.
|
||||
|
||||
### How it Works
|
||||
|
||||
When an annotated tag matching exact `vMAJOR.MINOR.PATCH` is pushed, the GitHub
|
||||
Actions workflow performs these ordered gates:
|
||||
|
||||
1. Confirms that the tag is annotated, points exactly at current `origin/main`,
|
||||
and matches every committed version source.
|
||||
2. Requires successful `verify`, `node20`, and `e2e` checks for that commit.
|
||||
3. Re-runs release verification, cross-browser E2E, and an unpublished relay
|
||||
container smoke test.
|
||||
4. Builds and locally validates Chrome/Firefox archives, checksums, AMO output,
|
||||
website output, archive parity, and manifests.
|
||||
5. Creates an attested **draft** GitHub Release.
|
||||
6. Publishes the multi-architecture relay image, verifies both platforms,
|
||||
attestation identity, digest, tag source, and a running health check.
|
||||
7. Makes the GitHub Release public only after every preceding gate succeeds.
|
||||
|
||||
The release workflow never writes to `main` and never derives shell code from a
|
||||
tag. Version changes must pass normal branch protection first.
|
||||
1. Validates that the tag is an annotated exact `vMAJOR.MINOR.PATCH` tag, points
|
||||
at current `origin/main`, and has successful `verify`, `node20`, and `e2e`
|
||||
checks. Invalid tags are rejected before any write.
|
||||
2. Extracts the validated version and uses the tagged commit timestamp so
|
||||
repeated preparation is deterministic.
|
||||
3. Updates and validates all version sources: `extension/manifest.base.json`,
|
||||
`shared/constants.js`, `package.json`, root metadata in `package-lock.json`,
|
||||
`website/version.json`, `website/template.html`, `website/llms.txt`, and both
|
||||
the README badge and release banner.
|
||||
4. Creates `chore(release): update versions to vX.Y.Z [skip ci]` and pushes it
|
||||
directly to `main`. A failed push stops every dependent release job.
|
||||
5. Checks out that exact prepared commit for full verification, cross-browser
|
||||
E2E, the unpublished relay health smoke, Chrome/Firefox/AMO/checksum/archive
|
||||
validation, website output, and the relay container build.
|
||||
6. Creates an attested **draft** GitHub Release after extension checks pass.
|
||||
7. Publishes the canonical lowercase image `ghcr.io/shik3i/koalasync`, then
|
||||
verifies both platforms, attestation identity, digest, tag source, and a
|
||||
running health check.
|
||||
8. Makes the GitHub Release public only after every preceding job succeeds.
|
||||
|
||||
---
|
||||
|
||||
@@ -40,35 +43,20 @@ tag. Version changes must pass normal branch protection first.
|
||||
|
||||
To release a new version (e.g., `v2.5.1`), follow these steps:
|
||||
|
||||
1. Create a release-preparation branch from current `main` and update every
|
||||
version source atomically:
|
||||
```bash
|
||||
git checkout main
|
||||
git pull origin main
|
||||
git checkout -b release/v2.5.1
|
||||
npm run prepare:release -- 2.5.1
|
||||
git add <reviewed-release-paths>
|
||||
git commit -m "release: prepare v2.5.1"
|
||||
npm run release:gate -- 2.5.1 --candidate
|
||||
```
|
||||
2. Push the already committed and candidate-gated changes, open a pull request,
|
||||
and wait for required `verify`, `node20`, and `e2e` checks.
|
||||
3. After the PR is merged, fast-forward local `main`, wait for `verify`,
|
||||
`node20`, and `e2e` on the merge commit, then run the final local gate. It
|
||||
refuses a dirty tree, a non-`main` branch, a commit different from
|
||||
`origin/main`, missing/failed required checks, version drift, non-AMD64
|
||||
Linux browser execution, or an unhealthy relay container:
|
||||
1. Fast-forward local `main`, confirm a clean tree at exact `origin/main`, and
|
||||
wait for `verify`, `node20`, and `e2e` on that commit:
|
||||
```bash
|
||||
git checkout main
|
||||
git pull --ff-only origin main
|
||||
npm run release:gate -- 2.5.1
|
||||
test -z "$(git status --porcelain=v1)"
|
||||
test "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)"
|
||||
```
|
||||
4. Only after that command succeeds, create an **annotated** tag on the exact
|
||||
checked commit:
|
||||
2. Create an **annotated** exact SemVer tag on that commit. Do not run
|
||||
`prepare:release`; the tag workflow owns version updates:
|
||||
```bash
|
||||
git tag -a v2.5.1 -m "Release v2.5.1"
|
||||
```
|
||||
5. Verify the tag target, then push it once:
|
||||
3. Verify the tag target, then push it once:
|
||||
```bash
|
||||
test "$(git rev-parse v2.5.1^{commit})" = "$(git rev-parse origin/main)"
|
||||
git push origin v2.5.1
|
||||
@@ -77,10 +65,10 @@ To release a new version (e.g., `v2.5.1`), follow these steps:
|
||||
Never reuse or move a published tag. Monitor every release job and verify both
|
||||
the public GitHub assets and GHCR digest before calling the release complete.
|
||||
|
||||
`npm run verify` or a host-only Playwright run is not a substitute for
|
||||
`release:gate`. The gate pins the official Playwright image to the exact
|
||||
lockfile version and forces `linux/amd64`, matching GitHub's Ubuntu runner even
|
||||
when the developer host is macOS or ARM64.
|
||||
`npm run release:gate -- MAJOR.MINOR.PATCH [--candidate]` remains an optional
|
||||
Linux/AMD64 parity diagnostic when release code changes. It prepares the target
|
||||
version only inside an isolated clone. It is not required for Markdown-only
|
||||
changes and does not replace the tag workflow's own gates.
|
||||
|
||||
The relay registry reference is always the canonical lowercase
|
||||
`ghcr.io/shik3i/koalasync`. Docker repository names reject uppercase characters;
|
||||
|
||||
+14
-8
@@ -10,8 +10,7 @@ npm run verify
|
||||
npm run lint
|
||||
npm run test:unit
|
||||
npm run test:coverage
|
||||
npm run prepare:release -- 3.1.5
|
||||
npm run release:gate -- 3.1.5 --candidate
|
||||
npm run release:gate -- 3.1.6 --candidate
|
||||
```
|
||||
|
||||
- `npm run build:extension` runs `scripts/build-extension.cjs`.
|
||||
@@ -19,8 +18,14 @@ npm run release:gate -- 3.1.5 --candidate
|
||||
- `npm run lint` runs ESLint across the repository.
|
||||
- `npm run test:unit` runs Vitest tests.
|
||||
- `npm run test:coverage` runs the same tests with the enforced coverage floor.
|
||||
- `npm run prepare:release -- MAJOR.MINOR.PATCH` updates every release-version source consistently before the release PR.
|
||||
- `npm run release:gate -- MAJOR.MINOR.PATCH --candidate` runs the complete release candidate in the lockfile-matched official Playwright Linux/AMD64 image, then builds and health-smokes the relay container. After merge, omit `--candidate`; final mode additionally requires clean current `main`, exact `origin/main`, and successful `verify`, `node20`, and `e2e` checks while simulating the release workflow's own pending preflight check.
|
||||
- `scripts/prepare-release.mjs` is an internal tag-workflow helper. It updates
|
||||
every release-version source from the validated tag and accepts the tagged
|
||||
commit timestamp for deterministic retries. Maintainers do not run it before
|
||||
tagging.
|
||||
- `npm run release:gate -- MAJOR.MINOR.PATCH --candidate` is an optional
|
||||
Linux/AMD64 parity diagnostic for release-code changes. It prepares the target
|
||||
version only inside its isolated clone, then runs verification, browser E2E,
|
||||
relay build, and health smoke. It is not required for Markdown-only changes.
|
||||
|
||||
## build-extension.cjs
|
||||
|
||||
@@ -100,10 +105,11 @@ integration gate. New unclassified files fail `npm run verify`.
|
||||
## Published Release Verification
|
||||
|
||||
Before publication, the release workflow validates the exact annotated SemVer
|
||||
tag, requires it to point at current `origin/main`, requires successful
|
||||
`verify`, `node20`, and `e2e` checks, and runs the complete gates again. It then
|
||||
creates a draft release, publishes and smoke-tests the relay image, and only
|
||||
afterwards makes the GitHub Release public. The published-asset gate runs:
|
||||
tag and required checks, prepares and validates every version source, pushes
|
||||
the generated version commit directly to `main`, and checks out that exact
|
||||
commit for all verification and builds. It then creates a draft release,
|
||||
publishes and smoke-tests the relay image, and only afterwards makes the GitHub
|
||||
Release public. The published-asset gate runs:
|
||||
|
||||
```bash
|
||||
node scripts/verify-published-release.mjs vMAJOR.MINOR.PATCH --repo Shik3i/KoalaSync
|
||||
|
||||
+20
-11
@@ -15,57 +15,62 @@ export function replaceExactly(text, pattern, replacement, label) {
|
||||
return text.replace(pattern, replacement);
|
||||
}
|
||||
|
||||
function writeJson(relativePath, update) {
|
||||
const absolutePath = path.join(repoRoot, relativePath);
|
||||
function writeJson(root, relativePath, update) {
|
||||
const absolutePath = path.join(root, relativePath);
|
||||
const value = JSON.parse(fs.readFileSync(absolutePath, 'utf8'));
|
||||
update(value);
|
||||
fs.writeFileSync(absolutePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
function updateText(relativePath, pattern, replacement, label) {
|
||||
const absolutePath = path.join(repoRoot, relativePath);
|
||||
function updateText(root, relativePath, pattern, replacement, label) {
|
||||
const absolutePath = path.join(root, relativePath);
|
||||
const current = fs.readFileSync(absolutePath, 'utf8');
|
||||
fs.writeFileSync(absolutePath, replaceExactly(current, pattern, replacement, label), 'utf8');
|
||||
}
|
||||
|
||||
export function prepareRelease(version, date = new Date()) {
|
||||
export function prepareRelease(version, date = new Date(), root = repoRoot) {
|
||||
versionFromTag(`v${version}`);
|
||||
const timestamp = date.toISOString().replace(/\.\d{3}Z$/u, 'Z');
|
||||
writeJson('package.json', value => { value.version = version; });
|
||||
writeJson('package-lock.json', value => {
|
||||
writeJson(root, 'package.json', value => { value.version = version; });
|
||||
writeJson(root, 'package-lock.json', value => {
|
||||
value.version = version;
|
||||
value.packages[''].version = version;
|
||||
});
|
||||
writeJson('extension/manifest.base.json', value => { value.version = version; });
|
||||
writeJson('website/version.json', value => {
|
||||
writeJson(root, 'extension/manifest.base.json', value => { value.version = version; });
|
||||
writeJson(root, 'website/version.json', value => {
|
||||
value.version = version;
|
||||
value.date = timestamp;
|
||||
});
|
||||
updateText(
|
||||
root,
|
||||
'shared/constants.js',
|
||||
/export const APP_VERSION = ["'][^"']+["'];/gu,
|
||||
`export const APP_VERSION = "${version}";`,
|
||||
'shared/constants.js'
|
||||
);
|
||||
updateText(
|
||||
root,
|
||||
'website/template.html',
|
||||
/"softwareVersion": "[^"]+"/gu,
|
||||
`"softwareVersion": "${version}"`,
|
||||
'website/template.html'
|
||||
);
|
||||
updateText(
|
||||
root,
|
||||
'website/llms.txt',
|
||||
/Current website release: .+/gu,
|
||||
`Current website release: ${version}`,
|
||||
'website/llms.txt'
|
||||
);
|
||||
updateText(
|
||||
root,
|
||||
'README.md',
|
||||
/Release-v\d+\.\d+\.\d+-blue/gu,
|
||||
`Release-v${version}-blue`,
|
||||
'README.md release badge'
|
||||
);
|
||||
updateText(
|
||||
root,
|
||||
'README.md',
|
||||
/New v\d+\.\d+\.\d+ Release!/gu,
|
||||
`New v${version} Release!`,
|
||||
@@ -77,8 +82,12 @@ export function prepareRelease(version, date = new Date()) {
|
||||
const isMainModule = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
|
||||
if (isMainModule) {
|
||||
try {
|
||||
if (process.argv.length !== 3) throw new Error('Usage: npm run prepare:release -- MAJOR.MINOR.PATCH');
|
||||
prepareRelease(process.argv[2]);
|
||||
if (process.argv.length < 3 || process.argv.length > 4) {
|
||||
throw new Error('Usage: prepare-release.mjs MAJOR.MINOR.PATCH [ISO_TIMESTAMP]');
|
||||
}
|
||||
const date = process.argv[3] ? new Date(process.argv[3]) : new Date();
|
||||
if (Number.isNaN(date.getTime())) throw new Error(`Invalid release timestamp: ${process.argv[3]}`);
|
||||
prepareRelease(process.argv[2], date);
|
||||
} catch (error) {
|
||||
console.error(`Release preparation failed: ${error.message}`);
|
||||
process.exitCode = 1;
|
||||
|
||||
@@ -1,5 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { replaceExactly } from './prepare-release.mjs';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { validateReleaseSourceVersion } from './release-preflight.mjs';
|
||||
import { prepareRelease, replaceExactly } from './prepare-release.mjs';
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const temporaryDirectories = [];
|
||||
const releaseSourcePaths = [
|
||||
'README.md',
|
||||
'extension/manifest.base.json',
|
||||
'package.json',
|
||||
'package-lock.json',
|
||||
'shared/constants.js',
|
||||
'website/llms.txt',
|
||||
'website/template.html',
|
||||
'website/version.json'
|
||||
];
|
||||
|
||||
function createReleaseFixture() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'koalasync-prepare-release-'));
|
||||
temporaryDirectories.push(root);
|
||||
for (const relativePath of releaseSourcePaths) {
|
||||
const target = path.join(root, relativePath);
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.copyFileSync(path.join(repoRoot, relativePath), target);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
function readFixture(root) {
|
||||
return Object.fromEntries(releaseSourcePaths.map(relativePath => [
|
||||
relativePath,
|
||||
fs.readFileSync(path.join(root, relativePath), 'utf8')
|
||||
]));
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of temporaryDirectories.splice(0)) {
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('release preparation helpers', () => {
|
||||
it('replaces one and only one version marker', () => {
|
||||
@@ -12,4 +54,37 @@ describe('release preparation helpers', () => {
|
||||
expect(() => replaceExactly('version=1 version=2', /version=\d+/gu, 'version=3', 'fixture'))
|
||||
.toThrow('fixture must contain exactly one release-version marker');
|
||||
});
|
||||
|
||||
it('updates and validates every release-version source, including both README markers', () => {
|
||||
const root = createReleaseFixture();
|
||||
prepareRelease('9.8.7', new Date('2030-04-05T06:07:08Z'), root);
|
||||
|
||||
expect(() => validateReleaseSourceVersion('9.8.7', root)).not.toThrow();
|
||||
expect(fs.readFileSync(path.join(root, 'README.md'), 'utf8')).toContain('Release-v9.8.7-blue');
|
||||
expect(fs.readFileSync(path.join(root, 'README.md'), 'utf8')).toContain('New v9.8.7 Release!');
|
||||
expect(JSON.parse(fs.readFileSync(path.join(root, 'website/version.json'), 'utf8')).date)
|
||||
.toBe('2030-04-05T06:07:08Z');
|
||||
});
|
||||
|
||||
it('is deterministic when repeated with the tag timestamp and does not duplicate markers', () => {
|
||||
const root = createReleaseFixture();
|
||||
const timestamp = new Date('2031-02-03T04:05:06Z');
|
||||
|
||||
prepareRelease('9.8.7', timestamp, root);
|
||||
const once = readFixture(root);
|
||||
prepareRelease('9.8.7', timestamp, root);
|
||||
|
||||
expect(readFixture(root)).toEqual(once);
|
||||
expect(once['README.md'].match(/Release-v9\.8\.7-blue/gu)).toHaveLength(1);
|
||||
expect(once['README.md'].match(/New v9\.8\.7 Release!/gu)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('rejects invalid versions before changing release sources', () => {
|
||||
const root = createReleaseFixture();
|
||||
const before = readFixture(root);
|
||||
|
||||
expect(() => prepareRelease('9.8.7;echo-unsafe', new Date('2030-01-01T00:00:00Z'), root))
|
||||
.toThrow('vMAJOR.MINOR.PATCH');
|
||||
expect(readFixture(root)).toEqual(before);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,6 @@ import path from 'node:path';
|
||||
import { versionFromTag } from './release-artifact-checks.mjs';
|
||||
import {
|
||||
parseCheckRuns,
|
||||
validateReleaseSourceVersion,
|
||||
validateRequiredChecks
|
||||
} from './release-preflight.mjs';
|
||||
|
||||
@@ -51,6 +50,8 @@ export function linuxGateCommand() {
|
||||
return [
|
||||
'git clone --no-local /src /work',
|
||||
'cd /work',
|
||||
'node scripts/prepare-release.mjs "$RELEASE_VERSION" "2030-01-01T00:00:00Z"',
|
||||
'node scripts/release-preflight.mjs --sources "$RELEASE_VERSION"',
|
||||
'npm ci',
|
||||
'npm ci --prefix server',
|
||||
'npm run verify',
|
||||
@@ -76,6 +77,26 @@ export function validateReleaseWorkflowContract(text) {
|
||||
if (/ghcr\.io\/\$\{\{\s*github\.repository\s*\}\}/u.test(workflow)) {
|
||||
throw new Error('release workflow must not derive a Docker image from case-preserving github.repository');
|
||||
}
|
||||
for (const marker of [
|
||||
'prepare-release:',
|
||||
'node scripts/prepare-release.mjs "$VERSION" "$RELEASE_TIMESTAMP"',
|
||||
'node scripts/release-preflight.mjs --sources "$VERSION"',
|
||||
'git commit -m "chore(release): update versions to v$VERSION [skip ci]"',
|
||||
'git push origin HEAD:main',
|
||||
'needs: [prepare-release, verify-prepared-release, release-extension-draft, release-server]',
|
||||
'gh release edit "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --draft=false --verify-tag'
|
||||
]) {
|
||||
if (!workflow.includes(marker)) {
|
||||
throw new Error(`release workflow must preserve automatic tag versioning: ${marker}`);
|
||||
}
|
||||
}
|
||||
const preparedCheckout = 'ref: ${{ needs.prepare-release.outputs.prepared-commit }}';
|
||||
if ((workflow.match(new RegExp(preparedCheckout.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'), 'gu')) || []).length < 3) {
|
||||
throw new Error('release workflow must use the prepared commit for verification and all release builds');
|
||||
}
|
||||
if (/git push origin HEAD:main\s*(?:\|\||;\s*true)/u.test(workflow)) {
|
||||
throw new Error('release workflow must stop when the automatic main push fails');
|
||||
}
|
||||
return image;
|
||||
}
|
||||
|
||||
@@ -134,7 +155,6 @@ async function smokeRelayImage(image) {
|
||||
|
||||
export async function runReleaseGate({ version, candidate }) {
|
||||
assertCleanTree();
|
||||
validateReleaseSourceVersion(version, repoRoot);
|
||||
validateReleaseWorkflowContract(fs.readFileSync(
|
||||
path.join(repoRoot, '.github/workflows/release.yml'), 'utf8'
|
||||
));
|
||||
@@ -145,6 +165,7 @@ export async function runReleaseGate({ version, candidate }) {
|
||||
run('docker', ['pull', '--platform', 'linux/amd64', playwrightImage]);
|
||||
run('docker', [
|
||||
'run', '--rm', '--platform', 'linux/amd64', '--ipc=host', '--env', 'CI=1',
|
||||
'--env', `RELEASE_VERSION=${version}`,
|
||||
'--volume', `${repoRoot}:/src:ro`, playwrightImage,
|
||||
'bash', '-lc', linuxGateCommand()
|
||||
]);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import fs from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
linuxGateCommand,
|
||||
@@ -33,7 +34,17 @@ describe('local release gate contract', () => {
|
||||
const valid = [
|
||||
'IMAGE: ghcr.io/shik3i/koalasync',
|
||||
'images: ${{ env.IMAGE }}',
|
||||
'subject-name: ${{ env.IMAGE }}'
|
||||
'subject-name: ${{ env.IMAGE }}',
|
||||
'prepare-release:',
|
||||
'node scripts/prepare-release.mjs "$VERSION" "$RELEASE_TIMESTAMP"',
|
||||
'node scripts/release-preflight.mjs --sources "$VERSION"',
|
||||
'git commit -m "chore(release): update versions to v$VERSION [skip ci]"',
|
||||
'git push origin HEAD:main',
|
||||
'ref: ${{ needs.prepare-release.outputs.prepared-commit }}',
|
||||
'ref: ${{ needs.prepare-release.outputs.prepared-commit }}',
|
||||
'ref: ${{ needs.prepare-release.outputs.prepared-commit }}',
|
||||
'needs: [prepare-release, verify-prepared-release, release-extension-draft, release-server]',
|
||||
'gh release edit "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --draft=false --verify-tag'
|
||||
].join('\n');
|
||||
expect(validateReleaseWorkflowContract(valid)).toBe('ghcr.io/shik3i/koalasync');
|
||||
expect(() => validateReleaseWorkflowContract(valid.replace(
|
||||
@@ -44,10 +55,25 @@ describe('local release gate contract', () => {
|
||||
.toThrow('case-preserving github.repository');
|
||||
});
|
||||
|
||||
it('enforces the automatic version commit, direct push, prepared source, and final publication contract', () => {
|
||||
const workflow = fs.readFileSync('.github/workflows/release.yml', 'utf8');
|
||||
expect(validateReleaseWorkflowContract(workflow)).toBe('ghcr.io/shik3i/koalasync');
|
||||
expect(() => validateReleaseWorkflowContract(workflow.replace(
|
||||
'git push origin HEAD:main',
|
||||
'git push origin HEAD:release'
|
||||
))).toThrow('automatic tag versioning');
|
||||
expect(() => validateReleaseWorkflowContract(workflow.replace(
|
||||
'git push origin HEAD:main',
|
||||
'git push origin HEAD:main || true'
|
||||
))).toThrow('stop when the automatic main push fails');
|
||||
});
|
||||
|
||||
it('runs the complete CI-equivalent dependency, verify, and browser sequence', () => {
|
||||
expect(linuxGateCommand()).toBe([
|
||||
'git clone --no-local /src /work',
|
||||
'cd /work',
|
||||
'node scripts/prepare-release.mjs "$RELEASE_VERSION" "2030-01-01T00:00:00Z"',
|
||||
'node scripts/release-preflight.mjs --sources "$RELEASE_VERSION"',
|
||||
'npm ci',
|
||||
'npm ci --prefix server',
|
||||
'npm run verify',
|
||||
|
||||
@@ -51,6 +51,14 @@ export function validateVersionSnapshot(expectedVersion, snapshot) {
|
||||
}
|
||||
}
|
||||
|
||||
function versionFromMarker(text, pattern, label) {
|
||||
const matches = [...String(text).matchAll(pattern)];
|
||||
if (matches.length !== 1) {
|
||||
throw new Error(`${label} must contain exactly one release-version marker`);
|
||||
}
|
||||
return matches[0][1];
|
||||
}
|
||||
|
||||
export function validateReleaseSourceVersion(expectedVersion, repoRoot = process.cwd()) {
|
||||
const readJson = relativePath => JSON.parse(fs.readFileSync(path.join(repoRoot, relativePath), 'utf8'));
|
||||
const packageJson = readJson('package.json');
|
||||
@@ -58,21 +66,46 @@ export function validateReleaseSourceVersion(expectedVersion, repoRoot = process
|
||||
const manifest = readJson('extension/manifest.base.json');
|
||||
const websiteVersion = readJson('website/version.json');
|
||||
const constants = fs.readFileSync(path.join(repoRoot, 'shared/constants.js'), 'utf8');
|
||||
const appVersion = /export const APP_VERSION = ["']([^"']+)["']/u.exec(constants)?.[1] || '';
|
||||
const websiteTemplate = fs.readFileSync(path.join(repoRoot, 'website/template.html'), 'utf8');
|
||||
const websiteLlms = fs.readFileSync(path.join(repoRoot, 'website/llms.txt'), 'utf8');
|
||||
const readme = fs.readFileSync(path.join(repoRoot, 'README.md'), 'utf8');
|
||||
validateVersionSnapshot(expectedVersion, {
|
||||
'package.json': packageJson.version,
|
||||
'package-lock.json': packageLock.version,
|
||||
'package-lock root package': packageLock.packages?.['']?.version,
|
||||
'extension manifest': manifest.version,
|
||||
'shared constants': appVersion,
|
||||
'website/version.json': websiteVersion.version
|
||||
'shared constants': versionFromMarker(
|
||||
constants,
|
||||
/export const APP_VERSION = ["']([^"']+)["'];/gu,
|
||||
'shared/constants.js'
|
||||
),
|
||||
'website/version.json': websiteVersion.version,
|
||||
'website template': versionFromMarker(
|
||||
websiteTemplate,
|
||||
/"softwareVersion": "([^"]+)"/gu,
|
||||
'website/template.html'
|
||||
),
|
||||
'website llms': versionFromMarker(
|
||||
websiteLlms,
|
||||
/Current website release: (\d+\.\d+\.\d+)/gu,
|
||||
'website/llms.txt'
|
||||
),
|
||||
'README release badge': versionFromMarker(
|
||||
readme,
|
||||
/Release-v(\d+\.\d+\.\d+)-blue/gu,
|
||||
'README.md release badge'
|
||||
),
|
||||
'README release banner': versionFromMarker(
|
||||
readme,
|
||||
/New v(\d+\.\d+\.\d+) Release!/gu,
|
||||
'README.md release banner'
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
export function verifyReleaseRef({ tag, repo }) {
|
||||
const version = versionFromTag(tag);
|
||||
validateRepositoryName(repo);
|
||||
validateReleaseSourceVersion(version);
|
||||
const tagRef = `refs/tags/${tag}`;
|
||||
if (run('git', ['cat-file', '-t', tagRef]) !== 'tag') {
|
||||
throw new Error(`${tag} must be an annotated tag`);
|
||||
@@ -88,16 +121,30 @@ export function verifyReleaseRef({ tag, repo }) {
|
||||
'--jq', '.check_runs[] | [.name, .conclusion, .html_url] | @tsv'
|
||||
]));
|
||||
validateRequiredChecks(checks);
|
||||
return { version, tagCommit };
|
||||
const releaseTimestamp = run('git', ['show', '-s', '--format=%cI', tagCommit]);
|
||||
return { version, tagCommit, releaseTimestamp };
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (process.argv[2] === '--sources') {
|
||||
if (process.argv.length !== 4) {
|
||||
throw new Error('Usage: release-preflight.mjs --sources MAJOR.MINOR.PATCH');
|
||||
}
|
||||
const version = versionFromTag(`v${process.argv[3]}`);
|
||||
validateReleaseSourceVersion(version);
|
||||
console.log(`Release sources match v${version}`);
|
||||
return;
|
||||
}
|
||||
const tag = process.env.GITHUB_REF_NAME || '';
|
||||
const repo = process.env.GITHUB_REPOSITORY || '';
|
||||
const outputPath = process.env.GITHUB_OUTPUT || '';
|
||||
const result = verifyReleaseRef({ tag, repo });
|
||||
if (!outputPath) throw new Error('GITHUB_OUTPUT is required');
|
||||
fs.appendFileSync(outputPath, `version=${result.version}\ntag_commit=${result.tagCommit}\n`, 'utf8');
|
||||
fs.appendFileSync(
|
||||
outputPath,
|
||||
`version=${result.version}\ntag_commit=${result.tagCommit}\nrelease_timestamp=${result.releaseTimestamp}\n`,
|
||||
'utf8'
|
||||
);
|
||||
console.log(`Release preflight accepted ${tag} at ${result.tagCommit}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ describe('release preflight helpers', () => {
|
||||
expect(() => parseCheckRuns('verify')).toThrow('Invalid check-run record');
|
||||
});
|
||||
|
||||
it('requires every release source to already match the tag version', () => {
|
||||
it('requires every prepared release source to match the tag version', () => {
|
||||
expect(() => validateVersionSnapshot('3.1.5', {
|
||||
package: '3.1.5',
|
||||
manifest: '3.1.5'
|
||||
|
||||
Reference in New Issue
Block a user