ci(docker): smoke-test image before push to block crash-on-startup re… #1257
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }} |