Dev #1242
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" | |
| - name: Build and push | |
| 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 }} |