mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-15 22:02:35 +00:00
Run CLI Docker validation on native architectures and publish tested images
This commit is contained in:
@@ -20,6 +20,7 @@ on:
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- '.dockerignore'
|
||||
- 'utils/cli-docker-*.mjs'
|
||||
workflow_call:
|
||||
inputs:
|
||||
force:
|
||||
@@ -28,7 +29,7 @@ on:
|
||||
outputs:
|
||||
e2e_passed:
|
||||
description: Whether the Docker CLI E2E suite passed
|
||||
value: ${{ jobs.validate.outputs.e2e_passed }}
|
||||
value: ${{ jobs.summary.outputs.e2e_passed }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -38,15 +39,61 @@ concurrency:
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
outputs:
|
||||
e2e_passed: ${{ steps.e2e.outcome == 'success' }}
|
||||
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
|
||||
|
||||
@@ -55,14 +102,18 @@ jobs:
|
||||
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/amd64
|
||||
cache-from: |
|
||||
type=gha,scope=cli-image-amd64
|
||||
type=gha,scope=cli-image
|
||||
# Keep amd64 validation from replacing the cache for both platforms.
|
||||
cache-to: type=gha,scope=cli-image-amd64,mode=max
|
||||
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
|
||||
@@ -81,6 +132,34 @@ jobs:
|
||||
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
|
||||
@@ -88,3 +167,26 @@ jobs:
|
||||
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"
|
||||
|
||||
@@ -31,6 +31,7 @@ on:
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- ".dockerignore"
|
||||
- "utils/cli-docker-*.mjs"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
@@ -49,28 +50,26 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
uses: ./.github/workflows/cli-docker-validation.yml
|
||||
with:
|
||||
force: ${{ github.event_name == 'workflow_dispatch' && inputs.force || false }}
|
||||
metadata:
|
||||
name: Select image publication metadata
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
build-and-push:
|
||||
needs: validate
|
||||
if: ${{ needs.validate.outputs.e2e_passed == 'true' || (github.event_name == 'workflow_dispatch' && inputs.force) }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
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)
|
||||
@@ -79,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"
|
||||
@@ -87,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
|
||||
@@ -97,39 +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: Build and push
|
||||
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,scope=cli-image-amd64
|
||||
type=gha,scope=cli-image
|
||||
cache-to: type=gha,scope=cli-image,mode=max
|
||||
- name: Assemble image publication
|
||||
env:
|
||||
IMAGE: ${{ needs.metadata.outputs.image }}
|
||||
IMAGE_TAGS: ${{ needs.metadata.outputs.tags }}
|
||||
PUSH: ${{ needs.metadata.outputs.push }}
|
||||
shell: bash
|
||||
run: |
|
||||
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
|
||||
|
||||
@@ -29,6 +29,7 @@ on:
|
||||
- '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/**'
|
||||
@@ -65,6 +66,7 @@ on:
|
||||
- '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,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
@@ -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.
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# Export the image which passed Docker E2E without rebuilding its dependencies.
|
||||
FROM validated-image
|
||||
@@ -327,7 +327,7 @@ The GitHub Actions workflow `.github/workflows/cli-deno-tests.yml` runs automati
|
||||
|
||||
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 tests the production Docker image on relevant pull requests and is also called before container publication. Validation runs without registry credentials. The publishing workflow, `.github/workflows/cli-docker.yml`, reuses the Docker build cache and retains the existing main-branch, release-tag, and manual publication conditions.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+178
-15
@@ -1,4 +1,4 @@
|
||||
import { readFileSync, mkdtempSync, rmSync } from "node:fs";
|
||||
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";
|
||||
@@ -7,12 +7,20 @@ 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; with?: Record<string, unknown> };
|
||||
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> }
|
||||
{
|
||||
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 {
|
||||
@@ -130,20 +138,175 @@ describe("CLI CI selection", () => {
|
||||
);
|
||||
expect(publication.jobs.validate.uses).toBe("./.github/workflows/cli-docker-validation.yml");
|
||||
expect(publication.jobs.validate.permissions).toEqual({ contents: "read" });
|
||||
expect(publication.jobs["build-and-push"].needs).toBe("validate");
|
||||
expect(publication.jobs["build-and-push"].if).toBe(
|
||||
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) }}"
|
||||
);
|
||||
const publishBuild = publication.jobs["build-and-push"].steps?.find((step) =>
|
||||
step.uses?.startsWith("docker/build-push-action@")
|
||||
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 }}"
|
||||
);
|
||||
expect(publishBuild?.with?.["cache-from"]).toBe(build?.with?.["cache-from"]);
|
||||
const validationScope = String(build?.with?.["cache-to"]).match(/scope=([^,\s]+)/)?.[1];
|
||||
const publicationScope = String(publishBuild?.with?.["cache-to"]).match(/scope=([^,\s]+)/)?.[1];
|
||||
expect(validationScope).toBeDefined();
|
||||
expect(publicationScope).toBeDefined();
|
||||
expect(publicationScope).not.toBe(validationScope);
|
||||
expect(publishBuild?.with?.["cache-from"]).toContain(`scope=${validationScope}`);
|
||||
expect(publishBuild?.with?.["cache-from"]).toContain(`scope=${publicationScope}`);
|
||||
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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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/");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user