Skip to content

ci(docker): smoke-test image before push to block crash-on-startup re… #1257

ci(docker): smoke-test image before push to block crash-on-startup re…

ci(docker): smoke-test image before push to block crash-on-startup re… #1257

Workflow file for this run

name: CI
on:
workflow_dispatch:
push:
branches: [main, master, dev, 'repro/**']
tags: ['v*']
pull_request:
branches: [main, master]
env:
NODE_VERSION_PRIMARY: '22'
jobs:
# ── Lint ──────────────────────────────────────────────────────────────────
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION_PRIMARY }}
cache: 'npm'
- run: npm ci
- run: npm run lint
# ── Typecheck (daemon + server) ───────────────────────────────────────────
typecheck:
name: Typecheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION_PRIMARY }}
cache: 'npm'
- run: npm ci
- name: Install server deps
run: npm ci
working-directory: server
- name: Daemon
run: npx tsc --noEmit
- name: Server
run: npx tsc --noEmit
working-directory: server
# ── Secret scanning ────────────────────────────────────────────────────────
secret-scan:
name: Secret Scan (gitleaks)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# ── Daemon unit tests ─────────────────────────────────────────────────────
unit-tests:
name: Unit Tests (Node ${{ matrix.node }})
runs-on: ubuntu-latest
strategy:
matrix:
node: ['20', '22', '24']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: 'npm'
- run: npm ci
- run: npm run build
- run: npm run test:unit
# ── macOS daemon unit tests ─────────────────────────────────────────────
macos-unit-tests:
name: Unit Tests (macOS)
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION_PRIMARY }}
cache: 'npm'
- name: Install tmux
run: brew install tmux
- name: Prime tmux server
run: tmux new-session -d -s init && tmux kill-session -t init
- run: npm ci
- run: npm run build
- run: npm run test:unit
# ── Windows unit tests (WezTerm backend) ──────────────────────────────────
windows-unit-tests:
name: Unit Tests (Windows)
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION_PRIMARY }}
cache: 'npm'
- run: npm ci
- run: npm run build
- name: Run Windows-specific unit tests
run: npx vitest run test/agent/wezterm.test.ts test/daemon/hook-send.test.ts test/daemon/env-injection.test.ts test/cli/send.test.ts test/util/windows-daemon.test.ts test/util/windows-upgrade-script.test.ts
env:
IMCODES_MUX: wezterm
windows-conpty-tests:
name: Unit Tests (Windows ConPTY)
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION_PRIMARY }}
cache: 'npm'
- run: npm ci
- run: npm run build
- name: Run Windows ConPTY / startup regression tests
run: npx vitest run test/agent/conpty.test.ts test/agent/drivers/drivers.test.ts test/util/windows-daemon.test.ts test/util/windows-upgrade-script.test.ts
# ── Web frontend tests ────────────────────────────────────────────────────
web-tests-unit:
name: Web Tests (Unit)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION_PRIMARY }}
cache: 'npm'
- run: npm ci
- run: npm ci
working-directory: web
- run: cd web && npx vitest run --config vitest.unit.config.ts
web-tests-components:
name: Web Tests (Components)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION_PRIMARY }}
cache: 'npm'
- run: npm ci
- run: npm ci
working-directory: web
- run: cd web && npx vitest run --config vitest.components.config.ts
# FileBrowser component test skipped in CI (OOM — renders full 1300-line component in jsdom).
# Run locally: cd web && npx vitest run --config vitest.filebrowser.config.ts
# ── Server unit tests ─────────────────────────────────────────────────────
server-tests:
name: Server Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION_PRIMARY }}
cache: 'npm'
cache-dependency-path: package-lock.json
- run: npm ci
- run: npm ci
working-directory: server
- run: npm run test:server
- name: Run server-native tests (auth-flow, proxy-addr — require server/node_modules)
run: npm test
working-directory: server
- name: Build server for runtime check
run: npm run build
working-directory: server
- name: Runtime import smoke test (catches import path errors that tsc misses)
run: node -e "import('./dist/server/src/index.js').then(() => { console.log('Server module loaded OK'); process.exit(0); }).catch(e => { console.error('IMPORT FAILED:', e.message); process.exit(1); })"
working-directory: server
# ── Server DB integration tests (testcontainers + real PostgreSQL) ─────────
server-db-tests:
name: Server DB Integration Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION_PRIMARY }}
cache: 'npm'
cache-dependency-path: server/package-lock.json
- run: npm ci
working-directory: server
- name: Run DB integration tests
run: npm run test:integration
working-directory: server
env:
TESTCONTAINERS_RYUK_DISABLED: 'true'
# ── E2E tests (tmux installed — tests run for real) ──────────────────────
e2e-tests:
name: E2E Tests
runs-on: ubuntu-latest
needs: [unit-tests]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION_PRIMARY }}
cache: 'npm'
- name: Install tmux
run: sudo apt-get install -y tmux
- name: Prime tmux server (ensures socket dir exists)
run: tmux new-session -d -s init && tmux kill-session -t init
- run: npm ci
- name: Run pipe-pane e2e tests
run: npx vitest run test/e2e/pipe-pane-stream.test.ts
- name: Run other e2e tests
run: npm run test:e2e
# ── Repo provider integration tests (gh/glab against public repos) ────────
repo-integration-tests:
name: Repo Provider Integration Tests
runs-on: ubuntu-latest
needs: [unit-tests]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION_PRIMARY }}
cache: 'npm'
- run: npm ci
- name: Install & authenticate glab (optional — tests skip if unavailable)
continue-on-error: true
run: |
curl -fsSL "https://gitlab.com/gitlab-org/cli/-/releases/v1.89.0/downloads/glab_1.89.0_linux_amd64.deb" -o glab.deb
sudo dpkg -i glab.deb
if [ -n "$GITLAB_TOKEN" ]; then
glab auth login --hostname gitlab.com --token "$GITLAB_TOKEN"
fi
env:
GITLAB_TOKEN: ${{ secrets.GITLAB_TOKEN }}
- name: Run integration tests
run: npm run test:integration
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# ── Coverage ──────────────────────────────────────────────────────────────
coverage:
name: Coverage Report
runs-on: ubuntu-latest
needs: [unit-tests, web-tests-unit, web-tests-components]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION_PRIMARY }}
cache: 'npm'
- name: Install tmux
run: sudo apt-get install -y tmux
- name: Prime tmux server
run: tmux new-session -d -s init && tmux kill-session -t init
- run: npm ci
- name: Install web deps (needed for tsx component tests)
run: npm ci
working-directory: web
- name: Install server deps (needed for server route tests)
run: npm ci
working-directory: server
- run: npm run build
- run: npm run test:coverage
- name: Upload to Codecov
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
- name: Comment PR with coverage diff
if: github.event_name == 'pull_request'
uses: davelosert/vitest-coverage-report-action@v2
# ── Publish to npm ────────────────────────────────────────────────────────
publish:
name: Publish to npm
runs-on: ubuntu-latest
needs: [docker]
if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev'
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION_PRIMARY }}
cache: 'npm'
registry-url: 'https://registry.npmjs.org'
- name: Remove deprecated always-auth from setup-node npmrc
run: |
if [ -n "${NPM_CONFIG_USERCONFIG:-}" ] && [ -f "$NPM_CONFIG_USERCONFIG" ]; then
sed -i '/^always-auth=/d' "$NPM_CONFIG_USERCONFIG"
fi
- run: npm install -g npm@11.11.1
- run: npm ci
- name: Install web deps
run: npm ci
working-directory: web
- name: Install server deps
run: npm ci
working-directory: server
- run: npm run build
- name: Set version
run: npm version ${{ needs.docker.outputs.npm_version }} --no-git-tag-version
- name: Publish (skip if version already on npm)
run: |
VERSION=${{ needs.docker.outputs.npm_version }}
if npm view "imcodes@${VERSION}" version >/dev/null 2>&1; then
echo "::warning::Version ${VERSION} already published — skipping"
elif [ "${{ github.ref }}" = "refs/heads/dev" ]; then
npm publish --tag dev --provenance --access public
else
npm publish --provenance --access public
fi
# ── Build & push Docker image ─────────────────────────────────────────────
docker:
name: Docker Build & Push
runs-on: ubuntu-latest
needs: [lint, typecheck, secret-scan, unit-tests, macos-unit-tests, windows-unit-tests, windows-conpty-tests, web-tests-unit, web-tests-components, server-tests, server-db-tests, e2e-tests]
if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev'
outputs:
npm_version: ${{ steps.version_meta.outputs.npm_version }}
permissions:
contents: write
packages: write
env:
IMAGE: ghcr.io/im4codes/imcodes
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # full history needed for git rev-list --count
- name: Get build timestamp
id: ts
run: echo "value=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Resolve tags
id: tags
run: |
set -euo pipefail
DATE_TAG="v$(date -u +%Y.%-m.%-d)"
SHA_SHORT="${GITHUB_SHA::7}"
if [ "${GITHUB_REF}" = "refs/heads/dev" ]; then
TAGS="${IMAGE}:dev"
TAGS="${TAGS},${IMAGE}:dev-${DATE_TAG#v}"
TAGS="${TAGS},${IMAGE}:dev-${DATE_TAG#v}-${SHA_SHORT}"
TAGS="${TAGS},${IMAGE}:dev-sha-${SHA_SHORT}"
else
TAGS="${IMAGE}:latest"
TAGS="${TAGS},${IMAGE}:${DATE_TAG}"
TAGS="${TAGS},${IMAGE}:${DATE_TAG}-${SHA_SHORT}"
TAGS="${TAGS},${IMAGE}:sha-${SHA_SHORT}"
fi
echo "tags=${TAGS}" >> "$GITHUB_OUTPUT"
echo "date_tag=${DATE_TAG}" >> "$GITHUB_OUTPUT"
- name: Get OTA version (commit count) and app version (semver)
id: ota
run: |
COMMIT_COUNT=$(( $(git rev-list --count HEAD) + 620 ))
echo "version=${COMMIT_COUNT}" >> "$GITHUB_OUTPUT"
echo "app_version=$(date -u +%Y.%-m).${COMMIT_COUNT}" >> "$GITHUB_OUTPUT"
- name: Resolve publish version
id: version_meta
run: |
set -euo pipefail
COMMIT_COUNT=$(( $(git rev-list --count HEAD) + 620 ))
if [ "${GITHUB_REF}" = "refs/heads/dev" ]; then
NPM_VERSION="$(date -u +%Y).$(date -u +%-m).${COMMIT_COUNT}-dev.${GITHUB_RUN_NUMBER}"
else
NPM_VERSION="$(date -u +%Y.%-m).${COMMIT_COUNT}"
fi
echo "npm_version=${NPM_VERSION}" >> "$GITHUB_OUTPUT"
# Build the image into the local docker daemon first so we can smoke-test
# it BEFORE pushing. Without this step, an image whose Node process
# crashes on startup (e.g. ERR_MODULE_NOT_FOUND from a missing prod dep)
# would still get pushed and auto-deployed to production.
- name: Build (load to local docker for smoke test)
uses: docker/build-push-action@v6
with:
context: .
file: server/Dockerfile
platforms: linux/amd64
tags: imcodes-smoke:test
build-args: |
BUILD_TIME=${{ steps.ts.outputs.value }}
OTA_VERSION=${{ steps.ota.outputs.version }}
APP_VERSION=${{ steps.version_meta.outputs.npm_version }}
cache-from: type=gha
cache-to: type=gha,mode=max
load: true
push: false
- name: Container startup smoke test
run: |
set -euo pipefail
# Override entrypoint to run an import-only check inside the actual
# production image. index.ts has an isMain guard so the import
# resolves all static deps (including all routes/* modules) without
# binding ports or hitting the database. Any ERR_MODULE_NOT_FOUND or
# other top-level eval failure surfaces here, before the image ships.
docker run --rm --entrypoint node imcodes-smoke:test \
-e "import('./dist/server/src/index.js').then(() => { console.log('OK: image loads cleanly'); process.exit(0); }).catch(e => { console.error('FAIL:', e.message); console.error(e.stack); process.exit(1); })"
- name: Build and push (cache hit — only pushes layers)
uses: docker/build-push-action@v6
with:
context: .
file: server/Dockerfile
platforms: linux/amd64
tags: ${{ steps.tags.outputs.tags }}
build-args: |
BUILD_TIME=${{ steps.ts.outputs.value }}
OTA_VERSION=${{ steps.ota.outputs.version }}
APP_VERSION=${{ steps.version_meta.outputs.npm_version }}
cache-from: type=gha
cache-to: type=gha,mode=max
push: true
- name: Create git tag
id: git_tag
if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git fetch --tags
NPM_VERSION="${{ steps.version_meta.outputs.npm_version }}"
GIT_TAG="v${NPM_VERSION}"
# Skip if this exact tag already exists (idempotent)
if ! git rev-parse "$GIT_TAG" >/dev/null 2>&1; then
git tag "$GIT_TAG"
git push origin "$GIT_TAG"
fi
# ── Android APK Build ────────────────────────────────────────────────────
android-release:
name: Android Release Build
runs-on: ubuntu-latest
needs: [lint, typecheck, secret-scan, unit-tests, web-tests-unit, web-tests-components, server-tests, e2e-tests]
if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/dev'
permissions:
contents: write
env:
KEYSTORE_FILE: ${{ github.workspace }}/web/android/app/release.keystore
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION_PRIMARY }}
cache: npm
cache-dependency-path: |
package-lock.json
web/package-lock.json
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
- uses: android-actions/setup-android@v3
- name: Install Android SDK packages
run: |
yes | sdkmanager --licenses
sdkmanager "platforms;android-36" "build-tools;36.0.0" "platform-tools"
- run: npm ci
- run: npm ci
working-directory: web
- name: Restore optional Android secrets
shell: bash
run: |
set -euo pipefail
if [ -n "${GOOGLE_SERVICES_JSON_BASE64:-}" ]; then
printf '%s' "$GOOGLE_SERVICES_JSON_BASE64" | base64 --decode > web/android/app/google-services.json
echo "Decoded google-services.json from secret."
else
echo "GOOGLE_SERVICES_JSON_BASE64 not set; Firebase push notifications will remain disabled in this build."
fi
if [ -n "${ANDROID_KEYSTORE_BASE64:-}" ]; then
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 --decode > web/android/app/release.keystore
echo "Decoded Android keystore from secret."
else
echo "ANDROID_KEYSTORE_BASE64 not set; assembleRelease will build without production signing."
fi
env:
GOOGLE_SERVICES_JSON_BASE64: ${{ secrets.GOOGLE_SERVICES_JSON_BASE64 }}
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
- name: Build web assets
working-directory: web
run: npm run build
- name: Sync Capacitor Android project
working-directory: web
run: npx cap sync android
- name: Build Android release APK + AAB
working-directory: web/android
run: ./gradlew assembleRelease bundleRelease --stacktrace
- name: Upload release APK artifact
uses: actions/upload-artifact@v4
with:
name: imcodes-android-release-apk
path: web/android/app/build/outputs/apk/release/*.apk
if-no-files-found: error
- name: Upload release AAB artifact
uses: actions/upload-artifact@v4
with:
name: imcodes-android-release-aab
path: web/android/app/build/outputs/bundle/release/*.aab
if-no-files-found: error
- name: Get version info
id: version
run: |
VERSION=$(grep versionName web/android/app/build.gradle | head -1 | sed 's/.*"\(.*\)".*/\1/')
COMMIT_SHORT=$(git rev-parse --short HEAD)
echo "tag=android-v${VERSION}-${COMMIT_SHORT}" >> "$GITHUB_OUTPUT"
echo "name=Android v${VERSION} (${COMMIT_SHORT})" >> "$GITHUB_OUTPUT"
- name: Rename artifacts with version
run: |
APK=$(ls web/android/app/build/outputs/apk/release/*.apk | head -1)
cp "$APK" "imcodes-${{ steps.version.outputs.tag }}.apk"
AAB=$(ls web/android/app/build/outputs/bundle/release/*.aab | head -1)
cp "$AAB" "imcodes-${{ steps.version.outputs.tag }}.aab"
- name: Create GitHub Release
if: github.ref == 'refs/heads/master'
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.version.outputs.tag }}
name: ${{ steps.version.outputs.name }}
files: |
imcodes-*.apk
imcodes-*.aab
generate_release_notes: true
make_latest: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}