Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 90 additions & 1 deletion .github/scripts/release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,94 @@ commit_to_git () {
) )
}

# A "final" release is X.Y.Z with no -prerelease suffix. Pre-releases like
# 3.19.4-rc2 are intentionally excluded from CHANGELOG heading insertion.
is_final_release () {
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]
}

# True if the given CHANGELOG already has a "## vX.Y.Z" heading for this version.
changelog_has_heading () {
grep -qE "^## v${VERSION//./\\.}( .*)?\$" "$1"
}

# True if a fresh "## vX.Y.Z" heading is due to be inserted into this CHANGELOG.
changelog_needs_heading () {
is_final_release && [ -f "$1" ] && ! changelog_has_heading "$1"
}

# Classify the "## vNEXT" section of a CHANGELOG: prints "nonempty", "empty"
# (immediately followed by another "## " heading or only blank lines), or
# "missing" (no vNEXT section at all).
vnext_state () {
awk '
/^## vNEXT( .*)?$/ { seen = 1; next }
seen && /^## / { print "empty"; found = 1; exit }
seen && /[^[:space:]]/ { print "nonempty"; found = 1; exit }
END { if (!found) print (seen ? "empty" : "missing") }
' "$1"
}

# Fail fast (before any version bump) if a heading is due for this release but
# there are no entries to release under "## vNEXT". A skipped heading
# (pre-release, or already present) needs no entries and is left alone.
assert_changelog_ready () {
CHANGELOG="$1"
changelog_needs_heading "$CHANGELOG" || return 0
case "$( vnext_state "$CHANGELOG" )" in
nonempty) ;;
empty)
err "ERROR: No changelog entries under '## vNEXT' in $CHANGELOG."
err "Add release notes there before releasing v$VERSION."
exit 2 ;;
missing)
err "ERROR: No '## vNEXT' section found in $CHANGELOG."
err "Cannot insert a heading for v$VERSION."
exit 2 ;;
esac
}

# Insert a "## vX.Y.Z" heading right below the "vNEXT" section, claiming all
# accumulated entries for this release. No-op for pre-releases and for versions
# that already have a heading.
inject_changelog_heading () {
CHANGELOG="$1"

if ! is_final_release; then
echo "==> Skipping CHANGELOG heading for pre-release $VERSION"
return
fi
if changelog_has_heading "$CHANGELOG"; then
echo "==> CHANGELOG already has a heading for v$VERSION, leaving as-is"
return
fi

echo "==> Adding CHANGELOG heading for v$VERSION"
tmp="$(mktemp)"
if awk -v ver="$VERSION" '
!done && /^## vNEXT( .*)?$/ {
print
print ""
print "## v" ver
done = 1
next
}
{ print }
END { exit (done ? 0 : 3) }
' "$CHANGELOG" > "$tmp"; then
mv "$tmp" "$CHANGELOG"
else
rm -f "$tmp"
err "WARNING: Could not find a '## vNEXT' section in"
err "$CHANGELOG; skipping CHANGELOG heading insertion."
fi
}

check_is_valid_version "$VERSION"

# Fail fast if a heading is due for this release but vNEXT has no entries
assert_changelog_ready "$ROOT/CHANGELOG.md"

# Run a fresh install to ensure the lock file isn't outdated before continuing
pnpm install --no-frozen-lockfile
git is-clean -v
Expand All @@ -104,4 +190,7 @@ done
# Update pnpm-lock.yaml with newly bumped versions
pnpm install --no-frozen-lockfile

commit_to_git "${COMMIT_MESSAGE}${VERSION}" "pnpm-lock.yaml" "packages/" "tools/"
# Add a CHANGELOG heading for this release if one doesn't exist yet
inject_changelog_heading "$ROOT/CHANGELOG.md"

commit_to_git "${COMMIT_MESSAGE}${VERSION}" "pnpm-lock.yaml" "packages/" "tools/" "CHANGELOG.md"
65 changes: 7 additions & 58 deletions .github/workflows/publish-docker-images.yml
Original file line number Diff line number Diff line change
@@ -1,22 +1,8 @@
name: Publish Docker images
name: Publish Docker image for Liveblocks dev server

# Builds and publishes the Liveblocks dev-server Docker image. Run manually after
# the `liveblocks` CLI is published to npm, passing the published CLI version.
on:
push:
tags:
- "v*"
branches:
- main
paths:
- "tools/liveblocks-cli/**"
- "packages/liveblocks-server/**"
- ".github/workflows/publish-docker-images.yml"
- ".github/workflows/docker-image.yml"
pull_request:
paths:
- "tools/liveblocks-cli/**"
- "packages/liveblocks-server/**"
- ".github/workflows/publish-docker-images.yml"
- ".github/workflows/docker-image.yml"
workflow_dispatch:
inputs:
version:
Expand All @@ -25,49 +11,12 @@ on:
description: "CLI package version to build Docker images for (e.g. 1.0.6, without v prefix). Images are tagged as a release with this version."

jobs:
version:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.v.outputs.version }}
is_release: ${{ steps.v.outputs.is_release }}
# Non-empty only for workflow_dispatch triggers. Passed to docker-image.yml
# so it can apply explicit version tags (type=raw) since there is no git tag
# for docker/metadata-action's type=semver to derive versions from.
release_version: ${{ steps.v.outputs.release_version }}
steps:
- name: Extract version
id: v
run: |
if [[ -n "${{ inputs.version }}" ]]; then
# External trigger with explicit version (e.g. from liveblocks-backend
# publish workflow). There is no git tag, so we pass the version
# explicitly for docker-image.yml to use as raw tags.
VERSION="${{ inputs.version }}"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "is_release=true" >> "$GITHUB_OUTPUT"
echo "release_version=${VERSION}" >> "$GITHUB_OUTPUT"
elif [[ "${{ github.event_name }}" == "push" && "${{ github.ref_type }}" == "tag" ]]; then
# Tag push (e.g. v1.0.6). docker/metadata-action derives semver tags
# from the git tag automatically, so release_version is left empty.
VERSION="${GITHUB_REF_NAME#v}"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "is_release=true" >> "$GITHUB_OUTPUT"
echo "release_version=" >> "$GITHUB_OUTPUT"
else
# Non-release build (branch push, PR). No version tags applied.
VERSION=$(npm view liveblocks version)
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "is_release=false" >> "$GITHUB_OUTPUT"
echo "release_version=" >> "$GITHUB_OUTPUT"
fi

publish:
needs: version
uses: ./.github/workflows/docker-image.yml
with:
image: liveblocks/dev-server
is_release: ${{ needs.version.outputs.is_release }}
release_version: ${{ needs.version.outputs.release_version }}
is_release: "true"
release_version: ${{ inputs.version }}
build-args: |
CLI_VERSION=${{ needs.version.outputs.version }}
VERSION=${{ needs.version.outputs.version }}
CLI_VERSION=${{ inputs.version }}
VERSION=${{ inputs.version }}
17 changes: 5 additions & 12 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,6 @@ jobs:
needs: check_for_code_changes
runs-on: ubuntu-latest

# Run the Liveblocks dev server on port 1154 for all tests
services:
liveblocks:
image: ghcr.io/liveblocks/dev-server:main
ports:
- 1154:1153

strategy:
matrix:
pkg:
Expand Down Expand Up @@ -112,7 +105,7 @@ jobs:

- name: Run unit tests
if: needs.check_for_code_changes.outputs.changes == 'true'
run: pnpm run test:ci --filter ${{ matrix.pkg }} -- --coverage
run: pnpm run test:ci --filter ${{ matrix.pkg }}

- name: Run type tests
if: needs.check_for_code_changes.outputs.changes == 'true'
Expand Down Expand Up @@ -169,7 +162,7 @@ jobs:
run: pnpm exec playwright install chromium --no-shell --with-deps
working-directory: e2e/next-sandbox

- name: Run Playwright tests
- name: Run Playwright tests (against real production backend)
if: needs.check_for_code_changes.outputs.changes == 'true'
run: pnpm run test --filter @liveblocks/next-sandbox

Expand Down Expand Up @@ -219,7 +212,7 @@ jobs:
run: pnpm exec playwright install chromium --no-shell --with-deps
working-directory: e2e/next-ai-kitchen-sink

- name: Run AI Playwright tests
- name: Run AI Playwright tests (against real production backend)
if: needs.check_for_code_changes.outputs.changes == 'true'
run: pnpm run test --filter @liveblocks/next-ai-kitchen-sink

Expand Down Expand Up @@ -265,7 +258,7 @@ jobs:
if: needs.check_for_code_changes.outputs.changes == 'true'
run: pnpm install --frozen-lockfile

- name: Run node-sandbox tests
- name: Run node-sandbox tests (against real production backend)
if: needs.check_for_code_changes.outputs.changes == 'true'
run: pnpm run test --filter node-sandbox

Expand Down Expand Up @@ -300,7 +293,7 @@ jobs:
if: needs.check_for_code_changes.outputs.changes == 'true'
run: pnpm install --frozen-lockfile

- name: Run e2e client specs tests
- name: Run e2e client specs tests (against local dev server)
if: needs.check_for_code_changes.outputs.changes == 'true'
run: pnpm run test:e2e
working-directory: packages/liveblocks-core
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
## vNEXT (not yet released)

## v3.19.4

### `@liveblocks/client`

- Fix `LiveList.push()` so concurrent pushes from multiple clients no longer
settle out of order.
- Fix a bug where a `LiveObject` key deleted while a client was offline would
reappear on reconnect, preventing the two clients from reconverging.
- Fix a bug where reconnecting would emit too many update notifications for
`LiveObject` keys whose values had not actually changed.
- Fix a bug where deleting a nested live value from a `LiveObject` omitted the
removed value (`deletedItem`) from the change notification.

## v3.19.3

### `@liveblocks/client`
Expand Down
17 changes: 11 additions & 6 deletions e2e/next-sandbox/test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,14 +62,19 @@ test.describe("Client logout", () => {

await test.step("Logout and verify reconnection", async () => {
await page.click("#logout");
await waitForJson(page, "#socketStatus_1", "connected");
await waitForJson(page, "#socketStatus_2", "connected");
await waitForJson(page, "#socketStatus_3", "connected");

// logout() clears the auth cache and reconnects every room, so each must
// re-auth before connecting. That path is bounded by AUTH_TIMEOUT (10s),
// well above waitForJson's 5s default, so widen the window past it.
const timeout = 15_000;
await waitForJson(page, "#socketStatus_1", "connected", { timeout });
await waitForJson(page, "#socketStatus_2", "connected", { timeout });
await waitForJson(page, "#socketStatus_3", "connected", { timeout });

// All three rooms get re-connected (and thus increment their connection ID)
await waitForJson(page, "#connectionId_1", connId1 + 1);
await waitForJson(page, "#connectionId_2", connId2 + 1);
await waitForJson(page, "#connectionId_3", connId3 + 1);
await waitForJson(page, "#connectionId_1", connId1 + 1, { timeout });
await waitForJson(page, "#connectionId_2", connId2 + 1, { timeout });
await waitForJson(page, "#connectionId_3", connId3 + 1, { timeout });
});
});

Expand Down
21 changes: 17 additions & 4 deletions e2e/next-sandbox/test/full-room.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ import { genRoomId, preparePages, waitForJson } from "./utils";

const TEST_URL = "http://localhost:3007/offline/";

// Launching 10 full Chromium instances starves CPU/network, so the first
// WebSocket connect legitimately takes longer than the 5s default. Give the
// connection waits extra headroom for this load scenario.
const LOAD_TIMEOUT = 15_000;

// These load tests sometimes fail on CI, possibly because they're too
// resource-intensive for the limited GitHub Actions runners (too many tabs
// open, or too slow, hitting the timeout limit). So for now, we'll just skip
Expand Down Expand Up @@ -36,8 +41,12 @@ test.describe("Room completely full", () => {
const batch = await preparePages(url, { n: 5 }); // ...of 5 windows each
batches.push(batch);
pagesToClose.push(...batch);
await waitForJson(batch, "#socketStatus", "connected");
await waitForJson(pagesToClose, "#numOthers", pagesToClose.length - 1);
await waitForJson(batch, "#socketStatus", "connected", {
timeout: LOAD_TIMEOUT,
});
await waitForJson(pagesToClose, "#numOthers", pagesToClose.length - 1, {
timeout: LOAD_TIMEOUT,
});
}

// Close the first batch of 5 windows
Expand All @@ -60,8 +69,12 @@ test.describe("Room completely full", () => {
const batch = await preparePages(url, { n: 5 }); // ...of 5 windows each
batches.push(batch);
pagesToClose.push(...batch);
await waitForJson(batch, "#socketStatus", "connected");
await waitForJson(pagesToClose, "#numOthers", pagesToClose.length - 1);
await waitForJson(batch, "#socketStatus", "connected", {
timeout: LOAD_TIMEOUT,
});
await waitForJson(pagesToClose, "#numOthers", pagesToClose.length - 1, {
timeout: LOAD_TIMEOUT,
});
}

// Try to open one more... this will FAIL, because the room can hold max 20 connections
Expand Down
20 changes: 12 additions & 8 deletions e2e/next-sandbox/test/multi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,15 +280,17 @@ test.describe("Multiple rooms (index)", () => {
await page.click("#mount_1");
await waitForJson(page, "#socketStatus_1", "connected");

// Ensure that, when initialRoom_1 equals the roomA value
await expectJson(page, "#initialRoom_1", roomA);
// Wait until storage has loaded and initialRoom_1 reflects roomA. A
// "connected" socket does not imply storage has finished loading, so a
// one-shot read here races and can still see null.
await waitForJson(page, "#initialRoom_1", roomA);

// Change room ID to roomB (without unmounting)
await page.fill("#input_1", roomB);
await waitForJson(page, "#socketStatus_1", "connected");

// Ensure that, when initialRoom_1 equals the roomB value
await expectJson(page, "#initialRoom_1", roomB);
// Wait until the room switch settles and initialRoom_1 reflects roomB.
await waitForJson(page, "#initialRoom_1", roomB);

// Unmount
await page.click("#unmount_1");
Expand Down Expand Up @@ -601,15 +603,17 @@ test.describe("Multiple rooms (global augmentation)", () => {
await page.click("#mount_1");
await waitForJson(page, "#socketStatus_1", "connected");

// Ensure that, when initialRoom_1 equals the roomA value
await expectJson(page, "#initialRoom_1", roomA);
// Wait until storage has loaded and initialRoom_1 reflects roomA. A
// "connected" socket does not imply storage has finished loading, so a
// one-shot read here races and can still see null.
await waitForJson(page, "#initialRoom_1", roomA);

// Change room ID to roomB (without unmounting)
await page.fill("#input_1", roomB);
await waitForJson(page, "#socketStatus_1", "connected");

// Ensure that, when initialRoom_1 equals the roomB value
await expectJson(page, "#initialRoom_1", roomB);
// Wait until the room switch settles and initialRoom_1 reflects roomB.
await waitForJson(page, "#initialRoom_1", roomB);

// Unmount
await page.click("#unmount_1");
Expand Down
Loading
Loading