Merge pull request #1250 from vrtmrz/ci/streamline-cli-validation

Reduce repeated CLI builds and isolate CI publication
This commit is contained in:
vorotamoroz
2026-10-14 20:48:16 +09:00
committed by GitHub
17 changed files with 1336 additions and 134 deletions
+61 -42
View File
@@ -8,14 +8,34 @@ on:
paths:
- '.github/workflows/cli-deno-tests.yml'
- 'src/apps/cli/**'
- 'test/bench-network/**'
- '!src/apps/cli/**/*.md'
- 'src/LiveSyncBaseCore.ts'
- 'src/common/**'
- 'src/serviceFeatures/**'
- 'src/serviceModules/**'
- 'src/integrations/**'
- 'src/modules/AbstractModule.ts'
- 'src/modules/main/**'
- 'src/modules/coreFeatures/**'
- 'manifest.json'
- 'tsconfig.json'
- 'package.json'
- 'package-lock.json'
pull_request:
paths:
- '.github/workflows/cli-deno-tests.yml'
- 'src/apps/cli/**'
- 'test/bench-network/**'
- '!src/apps/cli/**/*.md'
- 'src/LiveSyncBaseCore.ts'
- 'src/common/**'
- 'src/serviceFeatures/**'
- 'src/serviceModules/**'
- 'src/integrations/**'
- 'src/modules/AbstractModule.ts'
- 'src/modules/main/**'
- 'src/modules/coreFeatures/**'
- 'manifest.json'
- 'tsconfig.json'
- 'package.json'
- 'package-lock.json'
workflow_dispatch:
@@ -40,23 +60,34 @@ on:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
prepare:
runs-on: ubuntu-latest
timeout-minutes: 15
env:
NODE_OPTIONS: --max-old-space-size=3072
outputs:
task_matrix: ${{ steps.select.outputs.task_matrix }}
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Select task matrix
id: select
shell: bash
env:
SELECTED_TASK: ${{ github.event_name == 'workflow_dispatch' && inputs.test_task || 'test:ci' }}
run: |
set -euo pipefail
SELECTED_TASK="${{ github.event_name == 'workflow_dispatch' && inputs.test_task || 'test:ci' }}"
echo "[INFO] Selected task set: $SELECTED_TASK"
case "$SELECTED_TASK" in
test:ci)
TASK_MATRIX='["test:setup-put-cat","test:mirror","test:daemon","test:daemon-startup","test:push-pull","test:decoupled-vault","test:sync-two-local","test:sync-locked-remote","test:remote-commands","test:e2e-matrix:couchdb-enc0","test:e2e-matrix:couchdb-enc1","test:e2e-matrix:minio-enc0","test:e2e-matrix:minio-enc1"]'
TASK_MATRIX=$(jq -c . src/apps/cli/testdeno/ci-tasks.json)
;;
test:local)
TASK_MATRIX='["test:setup-put-cat","test:mirror","test:daemon"]'
@@ -72,6 +103,27 @@ jobs:
echo "task_matrix=$TASK_MATRIX" >> "$GITHUB_OUTPUT"
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: '24.x'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build CLI
working-directory: src/apps/cli
run: npm run build
- name: Upload CLI bundle
uses: actions/upload-artifact@v6
with:
name: cli-bundle
path: src/apps/cli/dist/
if-no-files-found: error
retention-days: 1
test:
needs: prepare
runs-on: ubuntu-latest
@@ -109,9 +161,11 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Build CLI
working-directory: src/apps/cli
run: npm run build
- name: Download CLI bundle
uses: actions/download-artifact@v8
with:
name: cli-bundle
path: src/apps/cli/dist/
- name: Create .test.env
working-directory: src/apps/cli
@@ -145,38 +199,3 @@ jobs:
run: |
docker stop couchdb-test minio-test relay-test coturn-test >/dev/null 2>&1 || true
docker rm couchdb-test minio-test relay-test coturn-test >/dev/null 2>&1 || true
compose-p2p-e2e:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Show Docker versions
run: |
docker --version
docker compose version
- name: Run Compose CLI P2P E2E
env:
CLI_E2E_TASK: test:p2p:ci
RELAY: ws://nostr-relay:7777/
PEERS_TIMEOUT: '20'
SYNC_TIMEOUT: '60'
LIVESYNC_USE_COTURN: '0'
TURN_SERVERS: none
LIVESYNC_P2P_PEERS_RETRY: '1'
LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS: '60000'
BENCH_LIVESYNC_TEST_TEE: '0'
run: docker compose -f test/bench-network/compose.yml run --build --rm bench-runner run-livesync-cli-e2e
- name: Show Compose diagnostics
if: failure()
run: |
docker compose -f test/bench-network/compose.yml ps
docker compose -f test/bench-network/compose.yml logs --no-color couchdb nostr-relay || true
- name: Stop Compose services
if: always()
run: docker compose -f test/bench-network/compose.yml down -v --remove-orphans
+192
View File
@@ -0,0 +1,192 @@
name: CLI Docker validation
on:
pull_request:
paths:
- '.github/workflows/cli-docker.yml'
- '.github/workflows/cli-docker-validation.yml'
- 'src/apps/cli/**'
- '!src/apps/cli/**/*.md'
- 'src/LiveSyncBaseCore.ts'
- 'src/common/**'
- 'src/serviceFeatures/**'
- 'src/serviceModules/**'
- 'src/integrations/**'
- 'src/modules/AbstractModule.ts'
- 'src/modules/main/**'
- 'src/modules/coreFeatures/**'
- 'manifest.json'
- 'tsconfig.json'
- 'package.json'
- 'package-lock.json'
- '.dockerignore'
- 'utils/cli-docker-*.mjs'
workflow_call:
inputs:
force:
type: boolean
default: false
outputs:
e2e_passed:
description: Whether the Docker CLI E2E suite passed
value: ${{ jobs.summary.outputs.e2e_passed }}
permissions:
contents: read
concurrency:
group: cli-docker-validation-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
bundle:
name: Build shared Docker CLI bundle
runs-on: ubuntu-24.04
timeout-minutes: 15
env:
NODE_OPTIONS: --max-old-space-size=3072
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: '22.x'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build CLI
working-directory: src/apps/cli
run: npm run build
- name: Upload Docker CLI bundle
uses: actions/upload-artifact@v6
with:
name: cli-docker-bundle
path: src/apps/cli/dist/
if-no-files-found: error
overwrite: true
retention-days: 1
validate:
name: Docker E2E (${{ matrix.arch }})
needs: bundle
runs-on: ${{ matrix.runner }}
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
runner: ubuntu-24.04
- arch: arm64
runner: ubuntu-24.04-arm
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Download Docker CLI bundle
uses: actions/download-artifact@v8
with:
name: cli-docker-bundle
path: _cli-bundle/
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build Docker test image
uses: docker/build-push-action@v7
with:
context: .
file: src/apps/cli/Dockerfile
build-args: CLI_BUNDLE_STAGE=prebuilt
load: true
outputs: type=oci,dest=${{ runner.temp }}/cli-image.tar
tags: livesync-cli
platforms: linux/${{ matrix.arch }}
cache-from: type=gha,scope=cli-runtime-${{ matrix.arch }}
cache-to: type=gha,scope=cli-runtime-${{ matrix.arch }},mode=max
- name: Verify image architecture
env:
ARCH: ${{ matrix.arch }}
run: test "$(docker image inspect livesync-cli --format '{{.Architecture}}')" = "$ARCH"
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: '24.x'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run CLI E2E (docker)
id: e2e
continue-on-error: ${{ github.event_name == 'workflow_dispatch' && inputs.force }}
working-directory: src/apps/cli
env:
CI: true
run: npm --ignore-scripts run test:e2e:docker:all
- name: Upload validated image
if: ${{ success() || (github.event_name == 'workflow_dispatch' && inputs.force && steps.e2e.outcome == 'failure') }}
uses: actions/upload-artifact@v6
with:
name: cli-docker-image-${{ matrix.arch }}
path: ${{ runner.temp }}/cli-image.tar
if-no-files-found: error
compression-level: 0
overwrite: true
retention-days: 1
- name: Record E2E result
if: always()
env:
ARCH: ${{ matrix.arch }}
OUTCOME: ${{ steps.e2e.outcome }}
run: node utils/cli-docker-artifacts.mjs record-result "$ARCH" "$OUTCOME" "$RUNNER_TEMP/cli-results"
- name: Upload E2E result
if: always()
uses: actions/upload-artifact@v6
with:
name: cli-docker-result-${{ matrix.arch }}
path: ${{ runner.temp }}/cli-results/
if-no-files-found: error
overwrite: true
retention-days: 1
- name: Stop test containers
if: always()
working-directory: src/apps/cli
run: |
bash ./util/couchdb-stop.sh >/dev/null 2>&1 || true
bash ./util/minio-stop.sh >/dev/null 2>&1 || true
bash ./util/p2p-stop.sh >/dev/null 2>&1 || true
summary:
name: Collect both Docker E2E results
needs: validate
if: always()
runs-on: ubuntu-24.04
timeout-minutes: 5
outputs:
e2e_passed: ${{ steps.summary.outputs.e2e_passed }}
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Download E2E results
uses: actions/download-artifact@v8
with:
pattern: cli-docker-result-*
merge-multiple: true
path: ${{ runner.temp }}/cli-results/
- name: Require both architecture results
id: summary
run: node utils/cli-docker-artifacts.mjs summary "$RUNNER_TEMP/cli-results" >> "$GITHUB_OUTPUT"
+162 -62
View File
@@ -1,6 +1,6 @@
# Build and push the CLI Docker image to GitHub Container Registry (GHCR).#
# Image tag format: <manifest-version>-<unix-epoch>-cli
# Example: 0.25.56-1743500000-cli
# Build and push the CLI Docker image to GitHub Container Registry (GHCR).
# Image tag format: <manifest-version>-cli
# Example: 1.0.36-cli
#
# Stable releases are also tagged with their major-minor version and 'latest'.
# Pre-releases receive immutable version and SHA-qualified tags only.
@@ -13,16 +13,25 @@ on:
- main
tags:
- "*.*.*-cli"
paths-ignore:
- "docs/**"
- "*.md"
- "images/**"
- "assets/**"
- "instruction_images/**"
- "src/apps/webapp/**"
- "src/apps/webpeer/**"
- ".github/workflows/release.yml"
- ".github/workflows/unit-ci.yml"
paths:
- ".github/workflows/cli-docker.yml"
- ".github/workflows/cli-docker-validation.yml"
- "src/apps/cli/**"
- "!src/apps/cli/**/*.md"
- "src/LiveSyncBaseCore.ts"
- "src/common/**"
- "src/serviceFeatures/**"
- "src/serviceModules/**"
- "src/integrations/**"
- "src/modules/AbstractModule.ts"
- "src/modules/main/**"
- "src/modules/coreFeatures/**"
- "manifest.json"
- "tsconfig.json"
- "package.json"
- "package-lock.json"
- ".dockerignore"
- "utils/cli-docker-*.mjs"
workflow_dispatch:
inputs:
dry_run:
@@ -36,20 +45,31 @@ on:
type: boolean
default: false
concurrency:
group: cli-docker-publish-${{ github.ref }}
cancel-in-progress: false
jobs:
build-and-push:
runs-on: ubuntu-latest
timeout-minutes: 90
metadata:
name: Select image publication metadata
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
contents: read
packages: write
outputs:
image: ${{ steps.meta.outputs.image }}
tags: ${{ steps.meta.outputs.tags }}
push: ${{ steps.meta.outputs.push }}
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Derive image tag
id: meta
env:
REF: ${{ github.ref }}
EVENT_NAME: ${{ github.event_name }}
DRY_RUN: ${{ inputs.dry_run }}
run: |
VERSION=$(jq -r '.version' manifest.json)
MAJOR_MINOR=$(echo "${VERSION}" | cut -d. -f1,2)
@@ -58,7 +78,7 @@ jobs:
# Build tag list based on the event and git ref
TAGS=""
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
if [[ "${REF}" == refs/tags/* ]]; then
if [[ "${VERSION}" == *-* ]]; then
# Pre-release builds must not advance stable moving tags.
TAGS="${IMAGE}:${VERSION}-cli,${IMAGE}:${VERSION}-sha-${SHORT_SHA}-cli"
@@ -66,7 +86,7 @@ jobs:
# Stable release builds
TAGS="${IMAGE}:${VERSION}-cli,${IMAGE}:${MAJOR_MINOR}-cli,${IMAGE}:latest,${IMAGE}:${VERSION}-sha-${SHORT_SHA}-cli"
fi
elif [[ "${{ github.ref }}" == refs/heads/main ]]; then
elif [[ "${REF}" == refs/heads/main ]]; then
# Bleeding-edge / nightly builds
TAGS="${IMAGE}:edge"
else
@@ -76,64 +96,144 @@ jobs:
# Determine if the image should be pushed
PUSH="true"
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
if [[ "${{ inputs.dry_run }}" == "true" ]]; then
if [[ "${EVENT_NAME}" == "workflow_dispatch" ]]; then
if [[ "${DRY_RUN}" == "true" ]]; then
PUSH="false"
fi
fi
echo "tags=${TAGS}" >> $GITHUB_OUTPUT
echo "push=${PUSH}" >> $GITHUB_OUTPUT
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
echo "tags=${TAGS}" >> "$GITHUB_OUTPUT"
echo "push=${PUSH}" >> "$GITHUB_OUTPUT"
validate:
uses: ./.github/workflows/cli-docker-validation.yml
with:
force: ${{ github.event_name == 'workflow_dispatch' && inputs.force || false }}
permissions:
contents: read
publish-images:
name: Prepare image publication (${{ matrix.arch }})
needs: [metadata, validate]
if: ${{ needs.validate.outputs.e2e_passed == 'true' || (github.event_name == 'workflow_dispatch' && inputs.force) }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
runner: ubuntu-24.04
- arch: arm64
runner: ubuntu-24.04-arm
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Download validated image
uses: actions/download-artifact@v8
with:
name: cli-docker-image-${{ matrix.arch }}
path: ${{ runner.temp }}/cli-image-archive/
- name: Extract OCI image
id: image-context
shell: bash
run: |
mkdir -p "$RUNNER_TEMP/cli-image"
tar -xf "$RUNNER_TEMP/cli-image-archive/cli-image.tar" -C "$RUNNER_TEMP/cli-image"
DIGEST=$(jq -er '.manifests | if length == 1 then .[0].digest else error("Expected one OCI image") end' "$RUNNER_TEMP/cli-image/index.json")
echo "digest=$DIGEST" >> "$GITHUB_OUTPUT"
- name: Log in to GitHub Container Registry
if: needs.metadata.outputs.push == 'true'
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Prepare architecture image
id: image
uses: docker/build-push-action@v7
with:
platforms: arm64
context: .
file: src/apps/cli/Dockerfile.publish
build-contexts: validated-image=oci-layout://${{ runner.temp }}/cli-image@${{ steps.image-context.outputs.digest }}
platforms: linux/${{ matrix.arch }}
outputs: type=image,name=${{ needs.metadata.outputs.image }},push-by-digest=true,name-canonical=true,push=${{ needs.metadata.outputs.push }}
- name: Record image digest
env:
ARCH: ${{ matrix.arch }}
DIGEST: ${{ steps.image.outputs.digest }}
run: node utils/cli-docker-artifacts.mjs record-digest "$ARCH" "$DIGEST" "$RUNNER_TEMP/cli-digests"
- name: Upload image digest
uses: actions/upload-artifact@v6
with:
name: cli-docker-digest-${{ matrix.arch }}
path: ${{ runner.temp }}/cli-digests/
if-no-files-found: error
overwrite: true
retention-days: 1
manifest:
name: Finalise multi-platform image
needs: [metadata, publish-images]
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Download image digests
uses: actions/download-artifact@v8
with:
pattern: cli-docker-digest-*
merge-multiple: true
path: ${{ runner.temp }}/cli-digests/
- name: Log in to GitHub Container Registry
if: needs.metadata.outputs.push == 'true'
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: "24.x"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Run CLI E2E (docker)
id: e2e
continue-on-error: ${{ github.event_name == 'workflow_dispatch' && inputs.force }}
working-directory: src/apps/cli
- name: Assemble image publication
env:
CI: true
run: npm run test:e2e:docker:all
- name: Stop test containers (safety net)
if: always()
working-directory: src/apps/cli
IMAGE: ${{ needs.metadata.outputs.image }}
IMAGE_TAGS: ${{ needs.metadata.outputs.tags }}
PUSH: ${{ needs.metadata.outputs.push }}
shell: bash
run: |
# Keep this as a safety net for future suites/steps that may leave containers running.
bash ./util/couchdb-stop.sh >/dev/null 2>&1 || true
bash ./util/minio-stop.sh >/dev/null 2>&1 || true
bash ./util/p2p-stop.sh >/dev/null 2>&1 || true
- name: Build and push
if: ${{ steps.e2e.outcome == 'success' || (github.event_name == 'workflow_dispatch' && inputs.force) }}
uses: docker/build-push-action@v7
with:
context: .
file: src/apps/cli/Dockerfile
push: ${{ steps.meta.outputs.push }}
tags: ${{ steps.meta.outputs.tags }}
platforms: linux/amd64,linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max
set -euo pipefail
node utils/cli-docker-artifacts.mjs references "$IMAGE" "$RUNNER_TEMP/cli-digests" > "$RUNNER_TEMP/image-references.txt"
mapfile -t REFERENCES < "$RUNNER_TEMP/image-references.txt"
IFS=',' read -ra TAGS <<< "$IMAGE_TAGS"
TAG_ARGS=()
for tag in "${TAGS[@]}"; do
TAG_ARGS+=(--tag "$tag")
done
if [[ "$PUSH" == "true" ]]; then
docker buildx imagetools create "${TAG_ARGS[@]}" "${REFERENCES[@]}"
else
printf 'Dry run: both architecture images built; publication skipped.\n'
printf 'Image: %s\n' "${REFERENCES[@]}"
printf 'Tag: %s\n' "${TAGS[@]}"
fi
+88
View File
@@ -0,0 +1,88 @@
name: CLI P2P Compose tests
on:
push:
branches:
- main
- beta
paths:
- '.github/workflows/cli-p2p-tests.yml'
- 'src/apps/cli/**'
- '!src/apps/cli/**/*.md'
- 'src/LiveSyncBaseCore.ts'
- 'src/common/**'
- 'src/serviceFeatures/**'
- 'src/serviceModules/**'
- 'src/integrations/**'
- 'src/modules/AbstractModule.ts'
- 'src/modules/main/**'
- 'src/modules/coreFeatures/**'
- 'manifest.json'
- 'tsconfig.json'
- 'package.json'
- 'package-lock.json'
- '.dockerignore'
- 'test/bench-network/**'
pull_request:
paths:
- '.github/workflows/cli-p2p-tests.yml'
- 'src/apps/cli/**'
- '!src/apps/cli/**/*.md'
- 'src/LiveSyncBaseCore.ts'
- 'src/common/**'
- 'src/serviceFeatures/**'
- 'src/serviceModules/**'
- 'src/integrations/**'
- 'src/modules/AbstractModule.ts'
- 'src/modules/main/**'
- 'src/modules/coreFeatures/**'
- 'manifest.json'
- 'tsconfig.json'
- 'package.json'
- 'package-lock.json'
- '.dockerignore'
- 'test/bench-network/**'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
compose-p2p-e2e:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Show Docker versions
run: |
docker --version
docker compose version
- name: Run Compose CLI P2P E2E
env:
CLI_E2E_TASK: test:p2p:ci
RELAY: ws://nostr-relay:7777/
PEERS_TIMEOUT: '20'
SYNC_TIMEOUT: '60'
LIVESYNC_USE_COTURN: '0'
TURN_SERVERS: none
LIVESYNC_P2P_PEERS_RETRY: '1'
LIVESYNC_P2P_RELAY_READY_TIMEOUT_MS: '60000'
BENCH_LIVESYNC_TEST_TEE: '0'
run: docker compose -f test/bench-network/compose.yml run --build --rm bench-runner run-livesync-cli-e2e
- name: Show Compose diagnostics
if: failure()
run: |
docker compose -f test/bench-network/compose.yml ps
docker compose -f test/bench-network/compose.yml logs --no-color couchdb nostr-relay || true
- name: Stop Compose services
if: always()
run: docker compose -f test/bench-network/compose.yml down -v --remove-orphans
+13 -7
View File
@@ -9,6 +9,7 @@ on:
- 'aggregator.html'
- 'src/**'
- 'test/browser-apps/**'
- 'manifest.json'
- 'package.json'
- 'package-lock.json'
- '.github/workflows/deploy-pages.yml'
@@ -17,18 +18,17 @@ on:
- 'aggregator.html'
- 'src/**'
- 'test/browser-apps/**'
- 'manifest.json'
- 'package.json'
- 'package-lock.json'
- '.github/workflows/deploy-pages.yml'
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
build:
@@ -84,9 +84,6 @@ jobs:
- name: Validate packaged Pages site
run: npm run test:browser-apps:pages
- name: Configure GitHub Pages
uses: actions/configure-pages@v6
- name: Upload GitHub Pages artifact
uses: actions/upload-pages-artifact@v5
with:
@@ -97,10 +94,19 @@ jobs:
if: github.event_name != 'pull_request'
needs: build
runs-on: ubuntu-latest
concurrency:
group: pages
cancel-in-progress: false
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Configure GitHub Pages
uses: actions/configure-pages@v6
- name: Deploy GitHub Pages
id: deployment
uses: actions/deploy-pages@v5
+26
View File
@@ -8,8 +8,11 @@ on:
- main
- beta
paths:
- 'manifest.json'
- 'updates.md'
- 'src/**'
- 'test/**'
- '_tools/**'
- 'package.json'
- 'package-lock.json'
- 'tsconfig.json'
@@ -19,8 +22,14 @@ on:
- 'eslint.config.mjs'
- 'eslint.config.common.mjs'
- 'eslint.community.config.mjs'
- 'terser.config.mjs'
- 'update-workspaces.mjs'
- 'version-bump.mjs'
- 'utils/check-compatibility.js'
- 'utils/livesync-commonlib-version.test.ts'
- 'utils/livesync-commonlib-version.ts'
- 'utils/*ci*.unit.spec.ts'
- 'utils/cli-docker-*.mjs'
- 'utils/release-*.mjs'
- 'utils/release-*.unit.spec.ts'
- 'utils/couchdb/**'
@@ -29,11 +38,18 @@ on:
- '.github/workflows/prepare-release.yml'
- '.github/workflows/finalise-release.yml'
- '.github/workflows/release.yml'
- '.github/workflows/cli-deno-tests.yml'
- '.github/workflows/cli-p2p-tests.yml'
- '.github/workflows/cli-docker-validation.yml'
- '.github/workflows/cli-docker.yml'
- '.github/workflows/unit-ci.yml'
pull_request:
paths:
- 'manifest.json'
- 'updates.md'
- 'src/**'
- 'test/**'
- '_tools/**'
- 'package.json'
- 'package-lock.json'
- 'tsconfig.json'
@@ -43,8 +59,14 @@ on:
- 'eslint.config.mjs'
- 'eslint.config.common.mjs'
- 'eslint.community.config.mjs'
- 'terser.config.mjs'
- 'update-workspaces.mjs'
- 'version-bump.mjs'
- 'utils/check-compatibility.js'
- 'utils/livesync-commonlib-version.test.ts'
- 'utils/livesync-commonlib-version.ts'
- 'utils/*ci*.unit.spec.ts'
- 'utils/cli-docker-*.mjs'
- 'utils/release-*.mjs'
- 'utils/release-*.unit.spec.ts'
- 'utils/couchdb/**'
@@ -53,6 +75,10 @@ on:
- '.github/workflows/prepare-release.yml'
- '.github/workflows/finalise-release.yml'
- '.github/workflows/release.yml'
- '.github/workflows/cli-deno-tests.yml'
- '.github/workflows/cli-p2p-tests.yml'
- '.github/workflows/cli-docker-validation.yml'
- '.github/workflows/cli-docker.yml'
- '.github/workflows/unit-ci.yml'
permissions:
+1
View File
@@ -29,6 +29,7 @@ cov_profile/**
coverage
src/apps/cli/dist/*
_cli-bundle/
src/apps/webapp/playwright-report/
src/apps/webapp/test-results/
_testdata/**
+12 -5
View File
@@ -50,7 +50,8 @@
# Stage 1 — builder
# Full Node.js environment to compile native modules and bundle the CLI.
# ─────────────────────────────────────────────────────────────────────────────
FROM node:22-slim AS builder
ARG CLI_BUNDLE_STAGE=builder
FROM --platform=$BUILDPLATFORM node:22-slim AS builder
# Build tools required by native Node.js addons (mainly leveldown)
RUN apt-get update \
@@ -67,8 +68,14 @@ RUN npm install
COPY . .
RUN cd src/apps/cli && npm run build
# CI may provide a prebuilt bundle in the build context.
FROM scratch AS prebuilt
COPY _cli-bundle/ /build/src/apps/cli/dist/
FROM ${CLI_BUNDLE_STAGE} AS cli-bundle
# ─────────────────────────────────────────────────────────────────────────────
# Stage 2 — runtime-deps
# Stage 4 — runtime-deps
# Install only the external (unbundled) packages that the CLI requires at
# runtime. Native addons are compiled here against the same base image that
# the final runtime stage uses.
@@ -89,7 +96,7 @@ RUN npm pkg delete devDependencies \
&& npm install --omit=dev
# ─────────────────────────────────────────────────────────────────────────────
# Stage 3 — runtime
# Stage 5 — runtime
# Minimal image: pre-compiled native modules + CLI bundle only.
# No build tools are included, keeping the image small.
# ─────────────────────────────────────────────────────────────────────────────
@@ -100,8 +107,8 @@ WORKDIR /app
# Copy pre-compiled external node_modules from runtime-deps stage
COPY --from=runtime-deps /deps/node_modules ./node_modules
# Copy the built CLI bundle from builder stage
COPY --from=builder /build/src/apps/cli/dist ./dist
# Copy the built CLI bundle from the selected stage
COPY --from=cli-bundle /build/src/apps/cli/dist ./dist
# Install the entrypoint wrapper with a deterministic mode, regardless of
# source checkout permissions.
+3
View File
@@ -0,0 +1,3 @@
# syntax=docker/dockerfile:1
# Export the image which passed Docker E2E without rebuilding its dependencies.
FROM validated-image
+17
View File
@@ -0,0 +1,17 @@
[
"test:settings-writeback",
"test:remote-administration-exit-codes",
"test:setup-put-cat",
"test:mirror",
"test:daemon",
"test:daemon-startup",
"test:push-pull",
"test:decoupled-vault",
"test:sync-two-local",
"test:sync-locked-remote",
"test:remote-commands",
"test:e2e-matrix:couchdb-enc0",
"test:e2e-matrix:couchdb-enc1",
"test:e2e-matrix:minio-enc0",
"test:e2e-matrix:minio-enc1"
]
+1 -17
View File
@@ -1,20 +1,4 @@
const TASKS = [
"test:settings-writeback",
"test:remote-administration-exit-codes",
"test:setup-put-cat",
"test:mirror",
"test:daemon",
"test:daemon-startup",
"test:push-pull",
"test:decoupled-vault",
"test:sync-two-local",
"test:sync-locked-remote",
"test:remote-commands",
"test:e2e-matrix:couchdb-enc0",
"test:e2e-matrix:couchdb-enc1",
"test:e2e-matrix:minio-enc0",
"test:e2e-matrix:minio-enc1",
] as const;
import TASKS from "./ci-tasks.json" with { type: "json" };
for (const [index, task] of TASKS.entries()) {
console.log(`\n[CLI E2E ${index + 1}/${TASKS.length}] ${task}`);
+5 -1
View File
@@ -323,7 +323,11 @@ deno task test:sync-two-local
## Continuous Integration
The GitHub Actions workflow `.github/workflows/cli-deno-tests.yml` runs automatically on pushes and pull requests affecting the CLI, executing the non-P2P test suite (`test:ci`). P2P tests (`test:p2p`) are excluded from automatic execution and must be run via manual dispatch (`workflow_dispatch`). You can optionally check the "Enable verbose and debug logging" checkbox during a manual dispatch to produce detailed trace logs for troubleshooting.
The GitHub Actions workflow `.github/workflows/cli-deno-tests.yml` runs automatically on pushes and pull requests affecting the CLI or its shared runtime inputs. It builds the CLI once, then distributes the bundle to the non-P2P test jobs. The local `test:ci` runner and the automatic matrix both read `ci-tasks.json`. During a manual dispatch, select a test subset and optionally enable 'Enable verbose and debug logging' for detailed troubleshooting traces.
The separate `.github/workflows/cli-p2p-tests.yml` workflow runs the canonical Compose P2P suite automatically for changes to the CLI, its shared runtime inputs, or `test/bench-network/`. A change confined to the Compose fixtures does not start the non-P2P matrix. Host-direct `test:p2p` tasks remain available for explicit diagnostics.
The `.github/workflows/cli-docker-validation.yml` workflow builds one production JavaScript bundle with Node.js 22, then tests Docker images in parallel on native amd64 and arm64 runners. Each image uses the shared bundle and prepares its own runtime dependencies. Validation runs without registry credentials, and both architecture results are collected before publication. The publishing workflow, `.github/workflows/cli-docker.yml`, imports the validated OCI image from each architecture, collects the image digests, and publishes the combined manifest. It retains the existing publication conditions for main, release tags, and manual dispatch. A dry run builds both images and checks the digest pair while skipping registry uploads and manifest publication.
---
+72
View File
@@ -0,0 +1,72 @@
import { readFileSync } from "node:fs";
import { minimatch } from "minimatch";
import { describe, expect, it } from "vitest";
import { parse } from "yaml";
type Trigger = "push" | "pull_request";
type Concurrency = {
group?: string;
"cancel-in-progress"?: boolean | string;
};
type Workflow = {
on?: Partial<Record<Trigger, { paths?: string[] }>>;
concurrency?: Concurrency;
jobs?: Record<string, { concurrency?: Concurrency }>;
};
function readWorkflow(filename: string): Workflow {
const source = readFileSync(new URL(`../.github/workflows/${filename}`, import.meta.url), "utf8");
return parse(source) as Workflow;
}
function matchesAnyPath(patterns: string[], filename: string): boolean {
return patterns.some((pattern) => minimatch(filename, pattern));
}
const pagesWorkflow = readWorkflow("deploy-pages.yml");
const unitWorkflow = readWorkflow("unit-ci.yml");
describe("CI workflow path filters", () => {
it.each(["push", "pull_request"] as const)("runs Pages checks for the manifest on %s", (trigger) => {
const patterns = pagesWorkflow.on?.[trigger]?.paths ?? [];
expect(matchesAnyPath(patterns, "manifest.json")).toBe(true);
});
it.each(["push", "pull_request"] as const)("runs unit CI for required inputs on %s", (trigger) => {
const patterns = unitWorkflow.on?.[trigger]?.paths ?? [];
const requiredInputs = [
"manifest.json",
"updates.md",
"_tools/bakei18n.ts",
"terser.config.mjs",
"utils/check-compatibility.js",
"utils/livesync-commonlib-version.test.ts",
"utils/livesync-commonlib-version.ts",
"utils/ci-pages.unit.spec.ts",
"utils/cli-ci.unit.spec.ts",
".github/workflows/cli-docker.yml",
".github/workflows/cli-deno-tests.yml",
".github/workflows/cli-p2p-tests.yml",
".github/workflows/cli-docker-validation.yml",
];
expect(requiredInputs.filter((filename) => !matchesAnyPath(patterns, filename))).toEqual([]);
expect(matchesAnyPath(patterns, "utils/unrelated.unit.spec.ts")).toBe(false);
});
});
describe("Pages workflow concurrency", () => {
it("isolates pull requests from publishing refs and cancels superseded pull request runs", () => {
expect(pagesWorkflow.concurrency).toEqual({
group: "${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}",
"cancel-in-progress": "${{ github.event_name == 'pull_request' }}",
});
});
it("serialises deployments without cancelling an active deployment", () => {
expect(pagesWorkflow.jobs?.deploy?.concurrency).toEqual({
group: "pages",
"cancel-in-progress": false,
});
});
});
+312
View File
@@ -0,0 +1,312 @@
import { readFileSync, mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
import { minimatch } from "minimatch";
import { describe, expect, it } from "vitest";
import { parse } from "yaml";
const root = new URL("../", import.meta.url);
type Step = { id?: string; run?: string; uses?: string; if?: string; with?: Record<string, unknown> };
type Workflow = {
on: Record<string, { paths?: string[]; [key: string]: unknown }>;
jobs: Record<
string,
{
steps?: Step[];
needs?: string | string[];
uses?: string;
if?: string;
permissions?: Record<string, string>;
"runs-on"?: string;
strategy?: { matrix?: { include?: Array<{ arch: string; runner: string }> } };
}
>;
};
function workflow(name: string): Workflow {
return parse(readFileSync(new URL(`.github/workflows/${name}`, root), "utf8")) as Workflow;
}
function matches(paths: string[], file: string): boolean {
let included = false;
for (const pattern of paths) {
const negative = pattern.startsWith("!");
if (minimatch(file, negative ? pattern.slice(1) : pattern)) included = !negative;
}
return included;
}
function localTasks(): string[] {
const runner = readFileSync(new URL("src/apps/cli/testdeno/run-ci-suite.ts", root), "utf8");
expect(runner).toContain('import TASKS from "./ci-tasks.json" with { type: "json" }');
return JSON.parse(readFileSync(new URL("src/apps/cli/testdeno/ci-tasks.json", root), "utf8")) as string[];
}
function selectedTasks(task: string): string[] {
const select = workflow("cli-deno-tests.yml").jobs.prepare.steps?.find((step) => step.id === "select");
expect(select?.run).toBeDefined();
const directory = mkdtempSync(join(tmpdir(), "livesync-ci-matrix-"));
const output = join(directory, "output");
try {
const script = select!.run!.replace(/\$\{\{[^}]+\}\}/g, task);
const result = spawnSync("bash", ["-c", script], {
cwd: root,
env: { ...process.env, SELECTED_TASK: task, GITHUB_OUTPUT: output },
encoding: "utf8",
});
expect(result.status, result.stderr).toBe(0);
const line = readFileSync(output, "utf8")
.split("\n")
.find((line) => line.startsWith("task_matrix="));
return JSON.parse(line!.slice("task_matrix=".length)) as string[];
} finally {
rmSync(directory, { recursive: true, force: true });
}
}
describe("CLI CI selection", () => {
it("runs every task in the local standard suite automatically", () => {
expect(selectedTasks("test:ci")).toEqual(localTasks());
expect(selectedTasks("test:ci")).toEqual(
expect.arrayContaining(["test:settings-writeback", "test:remote-administration-exit-codes"])
);
const tasks = JSON.parse(readFileSync(new URL("src/apps/cli/testdeno/deno.json", root), "utf8")).tasks;
expect(localTasks().filter((task) => !(task in tasks))).toEqual([]);
expect(new Set(localTasks()).size).toBe(localTasks().length);
});
it("keeps the selected manual subsets", () => {
expect(selectedTasks("test:local")).toEqual(["test:setup-put-cat", "test:mirror", "test:daemon"]);
expect(selectedTasks("test:e2e-matrix")).toEqual(
localTasks().filter((task) => task.startsWith("test:e2e-matrix:"))
);
});
it.each([
["cli-deno-tests.yml", "push"],
["cli-deno-tests.yml", "pull_request"],
["cli-p2p-tests.yml", "push"],
["cli-p2p-tests.yml", "pull_request"],
["cli-docker.yml", "push"],
["cli-docker-validation.yml", "pull_request"],
])("validates shared CLI runtime inputs in %s on %s", (name, event) => {
const paths = workflow(name).on[event].paths ?? [];
const inputs = [
"src/apps/cli/main.ts",
"src/LiveSyncBaseCore.ts",
"src/serviceFeatures/replicationScheduling.ts",
"src/common/replicatorProviders.ts",
"src/common/messages/en.ts",
"src/modules/AbstractModule.ts",
"src/modules/main/ModuleLiveSyncMain.ts",
"src/modules/coreFeatures/ModuleResolveMismatchedTweaks.ts",
"src/serviceModules/FileHandler.ts",
"src/integrations/cloudflare/turnCredentials.ts",
"manifest.json",
"package.json",
"package-lock.json",
"tsconfig.json",
];
expect(inputs.filter((file) => !matches(paths, file))).toEqual([]);
expect(matches(paths, "src/modules/features/ModuleObsidianSettingTab.ts")).toBe(false);
expect(matches(paths, "docs/settings.md")).toBe(false);
expect(matches(paths, "src/apps/cli/testdeno/CONTRIBUTING_TESTS.md")).toBe(false);
});
it("runs Compose alone for a change to its fixture", () => {
const file = "test/bench-network/compose.yml";
expect(matches(workflow("cli-p2p-tests.yml").on.pull_request.paths ?? [], file)).toBe(true);
expect(matches(workflow("cli-deno-tests.yml").on.pull_request.paths ?? [], file)).toBe(false);
expect(matches(workflow("cli-docker-validation.yml").on.pull_request.paths ?? [], file)).toBe(false);
});
it("passes a single CLI build to every standard test job", () => {
const { prepare, test } = workflow("cli-deno-tests.yml").jobs;
expect(test.needs).toBe("prepare");
expect(prepare.steps?.filter((step) => step.run === "npm run build")).toHaveLength(1);
expect(test.steps?.some((step) => step.run?.includes("npm run build"))).toBe(false);
const upload = prepare.steps?.find((step) => step.uses?.startsWith("actions/upload-artifact@"));
const download = test.steps?.find((step) => step.uses?.startsWith("actions/download-artifact@"));
expect(upload?.with?.["if-no-files-found"]).toBe("error");
expect(download?.with?.name).toBe(upload?.with?.name);
expect(download?.with?.path).toBe(upload?.with?.path);
});
it("keeps registry credentials out of Docker validation and gates publication on it", () => {
const validation = workflow("cli-docker-validation.yml");
const publication = workflow("cli-docker.yml");
expect(validation.on.push).toBeUndefined();
const validationJob = validation.jobs.validate;
expect(validationJob.steps?.some((step) => step.uses?.startsWith("docker/login-action@"))).toBe(false);
const build = validationJob.steps?.find((step) => step.uses?.startsWith("docker/build-push-action@"));
expect(build?.with?.load).toBe(true);
expect(build?.with?.push).toBeUndefined();
expect(validationJob.steps?.find((step) => step.id === "e2e")?.run).toBe(
"npm --ignore-scripts run test:e2e:docker:all"
);
expect(publication.jobs.validate.uses).toBe("./.github/workflows/cli-docker-validation.yml");
expect(publication.jobs.validate.permissions).toEqual({ contents: "read" });
const publishJob = publication.jobs["publish-images"];
expect(publishJob.needs).toEqual(["metadata", "validate"]);
expect(publishJob.if).toBe(
"${{ needs.validate.outputs.e2e_passed == 'true' || (github.event_name == 'workflow_dispatch' && inputs.force) }}"
);
expect(publishJob.strategy?.matrix?.include).toEqual(validationJob.strategy?.matrix?.include);
expect(publishJob["runs-on"]).toBe("${{ matrix.runner }}");
const publishBuild = publishJob.steps?.find((step) => step.uses?.startsWith("docker/build-push-action@"));
expect(publishBuild?.with?.file).toBe("src/apps/cli/Dockerfile.publish");
expect(publishBuild?.with?.["build-contexts"]).toBe(
"validated-image=oci-layout://${{ runner.temp }}/cli-image@${{ steps.image-context.outputs.digest }}"
);
const imageUpload = validationJob.steps?.find(
(step) => step.with?.name === "cli-docker-image-${{ matrix.arch }}"
);
const imageDownload = publishJob.steps?.find(
(step) => step.with?.name === "cli-docker-image-${{ matrix.arch }}"
);
expect(imageUpload?.with?.path).toBe("${{ runner.temp }}/cli-image.tar");
expect(build?.with?.outputs).toBe("type=oci,dest=${{ runner.temp }}/cli-image.tar");
expect(imageDownload?.with?.name).toBe(imageUpload?.with?.name);
const publishingInstructions = readFileSync(new URL("src/apps/cli/Dockerfile.publish", root), "utf8")
.split("\n")
.filter((line) => line && !line.startsWith("#"));
expect(publishingInstructions).toEqual(["FROM validated-image"]);
expect(publishBuild?.with?.outputs).toBe(
"type=image,name=${{ needs.metadata.outputs.image }},push-by-digest=true,name-canonical=true,push=${{ needs.metadata.outputs.push }}"
);
expect(publishJob.steps?.some((step) => step.uses?.startsWith("docker/setup-qemu-action@"))).toBe(false);
expect(publishJob.steps?.find((step) => step.uses?.startsWith("docker/login-action@"))?.if).toBe(
"needs.metadata.outputs.push == 'true'"
);
const manifestJob = publication.jobs.manifest;
expect(manifestJob.needs).toEqual(["metadata", "publish-images"]);
expect(manifestJob.steps?.find((step) => step.run?.includes("imagetools create"))?.run).toContain(
'if [[ "$PUSH" == "true" ]]; then'
);
});
it("builds and tests Docker images on both native architectures", () => {
const job = workflow("cli-docker-validation.yml").jobs.validate;
expect(job.strategy?.matrix?.include).toEqual([
{ arch: "amd64", runner: "ubuntu-24.04" },
{ arch: "arm64", runner: "ubuntu-24.04-arm" },
]);
expect(job["runs-on"]).toBe("${{ matrix.runner }}");
expect(job.steps?.some((step) => step.uses?.startsWith("docker/setup-qemu-action@"))).toBe(false);
});
it("builds the Docker JavaScript bundle once and passes it to the native image builds", () => {
const jobs = workflow("cli-docker-validation.yml").jobs;
expect(jobs.bundle?.steps?.filter((step) => step.run === "npm run build") ?? []).toHaveLength(1);
expect(jobs.validate.needs).toBe("bundle");
const upload = jobs.bundle?.steps?.find((step) => step.uses?.startsWith("actions/upload-artifact@"));
const download = jobs.validate.steps?.find((step) => step.uses?.startsWith("actions/download-artifact@"));
expect(download?.with?.name).toBe(upload?.with?.name);
expect(download?.with?.path).toBe("_cli-bundle/");
const build = jobs.validate.steps?.find((step) => step.uses?.startsWith("docker/build-push-action@"));
expect(build?.with?.["build-args"]).toBe("CLI_BUNDLE_STAGE=prebuilt");
});
it.each([false, true])("assembles both image digests with publication=%s", (push) => {
const step = workflow("cli-docker.yml").jobs.manifest.steps?.find((step) =>
step.run?.includes("imagetools create")
);
expect(step?.run).toBeDefined();
const directory = mkdtempSync(join(tmpdir(), "livesync-cli-manifest-"));
try {
const digests = join(directory, "cli-digests");
const bin = join(directory, "bin");
mkdirSync(digests);
mkdirSync(bin);
for (const [arch, char] of [
["amd64", "a"],
["arm64", "b"],
]) {
writeFileSync(
join(digests, `${arch}.json`),
JSON.stringify({ arch, digest: `sha256:${char.repeat(64)}` })
);
}
const capture = join(directory, "docker-args");
writeFileSync(join(bin, "docker"), '#!/bin/sh\nprintf \'%s\\n\' "$@" > "$CLI_TEST_CAPTURE"\n', {
mode: 0o755,
});
const image = "ghcr.io/test-owner/livesync-cli";
const tags = [`${image}:1.0.36-cli`, `${image}:latest`];
const result = spawnSync("bash", ["-c", step!.run!], {
cwd: root,
env: {
...process.env,
RUNNER_TEMP: directory,
IMAGE: image,
IMAGE_TAGS: tags.join(","),
PUSH: String(push),
PATH: `${bin}:${process.env.PATH}`,
CLI_TEST_CAPTURE: capture,
},
encoding: "utf8",
});
expect(result.status, result.stderr).toBe(0);
if (push) {
expect(readFileSync(capture, "utf8").trim().split("\n")).toEqual([
"buildx",
"imagetools",
"create",
"--tag",
tags[0],
"--tag",
tags[1],
`${image}@sha256:${"a".repeat(64)}`,
`${image}@sha256:${"b".repeat(64)}`,
]);
} else {
expect(existsSync(capture)).toBe(false);
expect(result.stdout).toContain("publication skipped");
}
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
it.each([
[
"1.0.36",
"refs/tags/1.0.36-cli",
"push",
"false",
true,
["1.0.36-cli", "1.0-cli", "latest", "1.0.36-sha-deadbee-cli"],
],
[
"1.0.36-rc.1",
"refs/tags/1.0.36-rc.1-cli",
"push",
"false",
true,
["1.0.36-rc.1-cli", "1.0.36-rc.1-sha-deadbee-cli"],
],
["1.0.36", "refs/heads/main", "push", "false", true, ["edge"]],
["1.0.36", "refs/heads/test", "workflow_dispatch", "true", false, ["1.0.36-dev-sha-deadbee-cli"]],
] as const)("preserves tag selection for %s on %s", (version, ref, event, dryRun, push, tags) => {
const metadata = workflow("cli-docker.yml").jobs.metadata.steps?.find((step) => step.id === "meta");
expect(metadata?.run).toBeDefined();
const directory = mkdtempSync(join(tmpdir(), "livesync-cli-image-metadata-"));
try {
const bin = join(directory, "bin");
const output = join(directory, "output");
mkdirSync(bin);
writeFileSync(join(directory, "manifest.json"), JSON.stringify({ version }));
writeFileSync(join(bin, "git"), "#!/bin/sh\nprintf 'deadbee\\n'\n", { mode: 0o755 });
const script = metadata!.run!.replace("${{ github.repository_owner }}", "test-owner");
const result = spawnSync("bash", ["-e", "-c", script], {
cwd: directory,
env: {
...process.env,
GITHUB_OUTPUT: output,
REF: ref,
EVENT_NAME: event,
DRY_RUN: dryRun,
PATH: `${bin}:${process.env.PATH}`,
},
encoding: "utf8",
});
expect(result.status, result.stderr).toBe(0);
const image = "ghcr.io/test-owner/livesync-cli";
expect(readFileSync(output, "utf8").trim().split("\n")).toEqual([
`image=${image}`,
`tags=${tags.map((tag) => `${image}:${tag}`).join(",")}`,
`push=${push}`,
]);
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
});
+157
View File
@@ -0,0 +1,157 @@
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import { imageReferences, summariseResults } from "./cli-docker-artifacts.mjs";
const helper = fileURLToPath(new URL("./cli-docker-artifacts.mjs", import.meta.url));
const temporaryDirectories: string[] = [];
const amd64Digest = `sha256:${"a".repeat(64)}`;
const arm64Digest = `sha256:${"b".repeat(64)}`;
afterEach(() => {
for (const directory of temporaryDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
function makeTemporaryDirectory(): string {
const directory = mkdtempSync(join(tmpdir(), "livesync-cli-docker-artifacts-"));
temporaryDirectories.push(directory);
return directory;
}
function runCli(args: string[]) {
return spawnSync(process.execPath, [helper, ...args], { encoding: "utf8" });
}
function writeJson(directory: string, name: string, value: unknown): void {
writeFileSync(join(directory, name), `${JSON.stringify(value)}\n`, "utf8");
}
describe("CLI Docker artifact aggregation", () => {
it.each([
[
[
{ arch: "amd64", e2e_passed: false },
{ arch: "arm64", e2e_passed: true },
],
false,
],
[
[
{ arch: "arm64", e2e_passed: true },
{ arch: "amd64", e2e_passed: false },
],
false,
],
])("summarises mixed results independent of record order", (records, expected) => {
expect(summariseResults(records)).toBe(expected);
});
it("reports success only when both architecture results pass", () => {
expect(
summariseResults([
{ arch: "amd64", e2e_passed: true },
{ arch: "arm64", e2e_passed: true },
])
).toBe(true);
});
it("rejects malformed, missing, and duplicate result records", () => {
expect(() =>
summariseResults([
{ arch: "amd64", e2e_passed: "true" },
{ arch: "arm64", e2e_passed: true },
])
).toThrow(/boolean/);
expect(() => summariseResults([{ arch: "amd64", e2e_passed: true }])).toThrow(/Exactly one/);
expect(() =>
summariseResults([
{ arch: "amd64", e2e_passed: true },
{ arch: "amd64", e2e_passed: false },
])
).toThrow(/Duplicate/);
});
it("builds deterministic architecture-ordered references and rejects invalid or duplicate digests", () => {
const records = [
{ arch: "arm64", digest: arm64Digest },
{ arch: "amd64", digest: amd64Digest },
];
expect(imageReferences("ghcr.io/example/cli", records)).toEqual([
`ghcr.io/example/cli@${amd64Digest}`,
`ghcr.io/example/cli@${arm64Digest}`,
]);
expect(() =>
imageReferences("ghcr.io/example/cli", [
{ arch: "amd64", digest: "sha256:BAD" },
{ arch: "arm64", digest: arm64Digest },
])
).toThrow(/Digest/);
expect(() =>
imageReferences("ghcr.io/example/cli", [
{ arch: "amd64", digest: amd64Digest },
{ arch: "arm64", digest: amd64Digest },
])
).toThrow(/distinct/);
expect(() => imageReferences("../../escape", records)).toThrow(/canonical image/);
});
it("rejects extra JSON files and filenames whose records name another architecture", () => {
const directory = makeTemporaryDirectory();
writeJson(directory, "amd64.json", { arch: "amd64", e2e_passed: true });
writeJson(directory, "arm64.json", { arch: "arm64", e2e_passed: true });
writeJson(directory, "extra.json", { arch: "arm64", e2e_passed: false });
expect(runCli(["summary", directory]).status).toBe(1);
rmSync(join(directory, "extra.json"));
writeJson(directory, "amd64.json", { arch: "arm64", e2e_passed: true });
expect(runCli(["summary", directory]).status).toBe(1);
});
it("prints a false summary successfully and fails when either result file is missing", () => {
const directory = makeTemporaryDirectory();
const results = join(directory, "results");
expect(runCli(["record-result", "amd64", "failure", results]).status).toBe(0);
expect(runCli(["record-result", "arm64", "success", results]).status).toBe(0);
const summary = runCli(["summary", results]);
expect(summary.status, summary.stderr).toBe(0);
expect(summary.stdout).toBe("e2e_passed=false\n");
const missing = join(directory, "missing");
mkdirSync(missing);
expect(runCli(["summary", missing]).status).toBe(1);
});
it("records results and digests without overwriting and prints references in canonical order", () => {
const directory = makeTemporaryDirectory();
const results = join(directory, "results");
const digests = join(directory, "digests");
expect(runCli(["record-result", "arm64", "success", results]).status).toBe(0);
expect(runCli(["record-result", "amd64", "skipped", results]).status).toBe(0);
expect(runCli(["record-result", "amd64", "success", results]).status).toBe(1);
expect(JSON.parse(readFileSync(join(results, "amd64.json"), "utf8"))).toEqual({
arch: "amd64",
e2e_passed: false,
});
expect(runCli(["record-digest", "arm64", arm64Digest, digests]).status).toBe(0);
expect(runCli(["record-digest", "amd64", amd64Digest, digests]).status).toBe(0);
const invalid = join(directory, "invalid");
expect(runCli(["record-digest", "amd64", "sha256:BAD", invalid]).status).toBe(1);
expect(existsSync(invalid)).toBe(false);
const references = runCli(["references", "ghcr.io/example/cli", digests]);
expect(references.status, references.stderr).toBe(0);
expect(references.stdout).toBe(`ghcr.io/example/cli@${amd64Digest}\nghcr.io/example/cli@${arm64Digest}\n`);
const traversal = runCli(["record-result", "../escape", "success", join(directory, "nested")]);
expect(traversal.status).toBe(1);
expect(readdirSync(directory)).not.toContain("escape.json");
});
});
+177
View File
@@ -0,0 +1,177 @@
import { readdir, mkdir, readFile, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";
const ARCHITECTURES = ["amd64", "arm64"];
const OUTCOMES = ["success", "failure", "cancelled", "skipped"];
const DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/;
const IMAGE_COMPONENT = "[a-z0-9]+(?:(?:[._]|__|[-]+)[a-z0-9]+)*";
const IMAGE_PATTERN = new RegExp(
`^(?:[a-z0-9]+(?:[.-][a-z0-9]+)*(?::[0-9]+)?/)?${IMAGE_COMPONENT}(?:/${IMAGE_COMPONENT})*(?::[A-Za-z0-9_][A-Za-z0-9_.-]{0,127})?$`
);
function assertArchitecture(arch) {
if (!ARCHITECTURES.includes(arch)) {
throw new Error(`Unsupported architecture: ${String(arch)}`);
}
}
function assertExactKeys(record, expectedKeys) {
if (record === null || typeof record !== "object" || Array.isArray(record)) {
throw new Error("Artifact record must be a JSON object");
}
const keys = Object.keys(record).sort();
const expected = [...expectedKeys].sort();
if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) {
throw new Error(`Artifact record must contain exactly: ${expected.join(", ")}`);
}
}
function orderedPair(records, expectedKeys) {
if (!Array.isArray(records) || records.length !== ARCHITECTURES.length) {
throw new Error("Exactly one artifact record is required for each architecture");
}
const recordsByArchitecture = new Map();
for (const record of records) {
assertExactKeys(record, expectedKeys);
assertArchitecture(record.arch);
if (recordsByArchitecture.has(record.arch)) {
throw new Error(`Duplicate artifact record for ${record.arch}`);
}
recordsByArchitecture.set(record.arch, record);
}
return ARCHITECTURES.map((arch) => {
const record = recordsByArchitecture.get(arch);
if (!record) {
throw new Error(`Missing artifact record for ${arch}`);
}
return record;
});
}
function validateImage(image) {
if (typeof image !== "string" || !image || /\s|@/.test(image) || !IMAGE_PATTERN.test(image)) {
throw new Error("Image must be a canonical image name without whitespace or a digest");
}
}
function validateDigest(digest) {
if (typeof digest !== "string" || !DIGEST_PATTERN.test(digest)) {
throw new Error("Digest must match sha256:<64 lowercase hexadecimal characters>");
}
}
export function summariseResults(records) {
const orderedRecords = orderedPair(records, ["arch", "e2e_passed"]);
for (const { e2e_passed } of orderedRecords) {
if (typeof e2e_passed !== "boolean") {
throw new Error("e2e_passed must be a boolean");
}
}
return orderedRecords.every(({ e2e_passed }) => e2e_passed);
}
export function imageReferences(image, records) {
validateImage(image);
const orderedRecords = orderedPair(records, ["arch", "digest"]);
const digests = orderedRecords.map(({ digest }) => {
validateDigest(digest);
return digest;
});
if (new Set(digests).size !== digests.length) {
throw new Error("Each architecture must have a distinct image digest");
}
return digests.map((digest) => `${image}@${digest}`);
}
async function writeRecord(arch, directory, record) {
assertArchitecture(arch);
await mkdir(directory, { recursive: true });
await writeFile(resolve(directory, `${arch}.json`), `${JSON.stringify(record)}\n`, { flag: "wx" });
}
async function readRecords(directory) {
const entries = await readdir(directory, { withFileTypes: true });
const jsonEntries = entries.filter((entry) => entry.name.toLowerCase().endsWith(".json"));
const expectedNames = ARCHITECTURES.map((arch) => `${arch}.json`);
const actualNames = jsonEntries.map((entry) => entry.name).sort();
if (actualNames.length !== expectedNames.length || expectedNames.some((name) => !actualNames.includes(name))) {
throw new Error(`Directory must contain exactly ${expectedNames.join(" and ")}`);
}
return Promise.all(
ARCHITECTURES.map(async (arch) => {
const name = `${arch}.json`;
const entry = jsonEntries.find((candidate) => candidate.name === name);
if (!entry?.isFile()) {
throw new Error(`${name} must be a regular file`);
}
let record;
try {
record = JSON.parse(await readFile(resolve(directory, name), "utf8"));
} catch (error) {
throw new Error(`Could not read ${name}: ${error instanceof Error ? error.message : String(error)}`);
}
if (record?.arch !== arch) {
throw new Error(`${name} must contain an ${arch} record`);
}
return record;
})
);
}
async function main(args) {
const [command, ...values] = args;
switch (command) {
case "record-result": {
if (values.length !== 3) {
throw new Error("Usage: record-result ARCH OUTCOME DIRECTORY");
}
const [arch, outcome, directory] = values;
assertArchitecture(arch);
if (!OUTCOMES.includes(outcome)) {
throw new Error(`Unsupported outcome: ${outcome}`);
}
await writeRecord(arch, directory, { arch, e2e_passed: outcome === "success" });
return;
}
case "summary": {
if (values.length !== 1) {
throw new Error("Usage: summary DIRECTORY");
}
const records = await readRecords(values[0]);
process.stdout.write(`e2e_passed=${summariseResults(records)}\n`);
return;
}
case "record-digest": {
if (values.length !== 3) {
throw new Error("Usage: record-digest ARCH DIGEST DIRECTORY");
}
const [arch, digest, directory] = values;
assertArchitecture(arch);
validateDigest(digest);
await writeRecord(arch, directory, { arch, digest });
return;
}
case "references": {
if (values.length !== 2) {
throw new Error("Usage: references IMAGE DIRECTORY");
}
const [image, directory] = values;
const records = await readRecords(directory);
process.stdout.write(`${imageReferences(image, records).join("\n")}\n`);
return;
}
default:
throw new Error("Usage: cli-docker-artifacts.mjs <record-result|summary|record-digest|references> ...");
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
main(process.argv.slice(2)).catch((error) => {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
});
}
+37
View File
@@ -0,0 +1,37 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const root = new URL("../", import.meta.url);
const dockerfile = readFileSync(new URL("src/apps/cli/Dockerfile", root), "utf8");
const dockerfileLines = dockerfile.split(/\r?\n/);
const gitignoreLines = readFileSync(new URL(".gitignore", root), "utf8").split(/\r?\n/);
function stageLine(stage: string): string | undefined {
return dockerfileLines.find((line) => /^FROM\b/i.test(line) && new RegExp(`\\bAS\\s+${stage}$`, "i").test(line));
}
describe("CLI Dockerfile bundle stages", () => {
it("keeps the default build self-contained from source", () => {
expect(dockerfileLines).toContain("ARG CLI_BUNDLE_STAGE=builder");
expect(stageLine("builder")).toBe("FROM --platform=$BUILDPLATFORM node:22-slim AS builder");
expect(dockerfileLines).toContain("COPY . .");
expect(dockerfileLines).toContain("RUN cd src/apps/cli && npm run build");
expect(dockerfileLines).toContain("FROM ${CLI_BUNDLE_STAGE} AS cli-bundle");
expect(dockerfileLines).toContain("COPY --from=cli-bundle /build/src/apps/cli/dist ./dist");
});
it("provides an explicit stage for a prebuilt bundle", () => {
expect(stageLine("prebuilt")).toBe("FROM scratch AS prebuilt");
expect(dockerfileLines).toContain("COPY _cli-bundle/ /build/src/apps/cli/dist/");
expect(dockerfileLines).toContain("FROM ${CLI_BUNDLE_STAGE} AS cli-bundle");
});
it("builds source on the host platform and native dependencies in the target stages", () => {
expect(stageLine("runtime-deps")).toBe("FROM node:22-slim AS runtime-deps");
expect(dockerfileLines).toContain("FROM node:22-slim");
});
it("ignores the temporary prebuilt bundle directory", () => {
expect(gitignoreLines).toContain("_cli-bundle/");
});
});