name: Publish EE Artifacts on: workflow_dispatch: inputs: push: description: Push images and the Helm chart to GHCR required: false default: false type: boolean chart_version: description: Helm chart version for manual publishes, for example 0.17.1 required: false default: "" type: string push: branches: - "dev" tags: - "v*" pull_request: paths: - ".github/workflows/publish-ee-images.yml" - "packaging/docker/Dockerfile.den" - "packaging/docker/Dockerfile.den-gateway" - "packaging/docker/Dockerfile.den-web" - "packaging/docker/Dockerfile.inference" - "packaging/helm/openwork-ee/**" - "ee/apps/den-api/**" - "ee/apps/den-gateway/**" - "ee/apps/den-web/**" - "ee/apps/inference/**" - "ee/packages/**" - "packages/automations/**" - "packages/codemode/**" - "packages/connect-link/**" - "packages/email/**" - "packages/enterprise-mcp-client/**" - "packages/headless-threads/**" - "packages/install-config/**" - "packages/mcp-apps/**" - "packages/types/**" - "packages/ui/**" - "pnpm-lock.yaml" - "pnpm-workspace.yaml" - "package.json" concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' || github.ref == 'refs/heads/dev' }} permissions: contents: read packages: write env: REGISTRY: ghcr.io jobs: artifact-version: name: Resolve artifact version runs-on: ubuntu-latest outputs: chart_version: ${{ steps.version.outputs.chart_version }} publish: ${{ steps.version.outputs.publish }} steps: - name: Resolve version id: version shell: bash env: INPUT_CHART_VERSION: ${{ inputs.chart_version }} INPUT_PUSH: ${{ inputs.push }} run: | publish="false" if [ "${GITHUB_EVENT_NAME}" = "push" ]; then publish="true" elif [ "$INPUT_PUSH" = "true" ]; then publish="true" fi chart_version="$INPUT_CHART_VERSION" if [[ "${GITHUB_REF_TYPE}" = "tag" && "${GITHUB_REF_NAME}" == v* ]]; then chart_version="${GITHUB_REF_NAME#v}" fi if [ "$publish" = "true" ] && [ -z "$chart_version" ] && [ "${GITHUB_EVENT_NAME}" != "push" ]; then echo "chart_version is required when manually publishing from a non-tag ref" >&2 exit 1 fi echo "chart_version=$chart_version" >> "$GITHUB_OUTPUT" echo "publish=$publish" >> "$GITHUB_OUTPUT" helm-chart: name: Validate Helm chart runs-on: ubuntu-latest needs: artifact-version steps: - name: Checkout uses: actions/checkout@v6 - name: Set up Helm uses: azure/setup-helm@v4 - name: Lint chart run: helm lint packaging/helm/openwork-ee - name: Render chart run: helm template openwork-ee packaging/helm/openwork-ee --set ingress.enabled=true --set inference.enabled=true - name: Run chart tests run: | for test in packaging/helm/openwork-ee/tests/*.sh; do bash "$test" done - name: Package chart if: ${{ needs.artifact-version.outputs.chart_version != '' }} run: | helm package packaging/helm/openwork-ee \ --version "${{ needs.artifact-version.outputs.chart_version }}" \ --app-version "${{ needs.artifact-version.outputs.chart_version }}" \ --destination dist/charts - name: Log in to GHCR for Helm if: ${{ needs.artifact-version.outputs.publish == 'true' && needs.artifact-version.outputs.chart_version != '' }} run: | printf '%s' "${{ secrets.GITHUB_TOKEN }}" | helm registry login "${{ env.REGISTRY }}" \ --username "${{ github.actor }}" \ --password-stdin - name: Publish chart if: ${{ needs.artifact-version.outputs.publish == 'true' && needs.artifact-version.outputs.chart_version != '' }} run: helm push "dist/charts/openwork-ee-${{ needs.artifact-version.outputs.chart_version }}.tgz" "oci://${{ env.REGISTRY }}/${{ github.repository_owner }}/charts" ee-images: name: Build ${{ matrix.image }} runs-on: ubuntu-latest needs: artifact-version strategy: fail-fast: false matrix: include: - image: openwork-den-api dockerfile: packaging/docker/Dockerfile.den port: 8788 health_path: /health smoke_env: >- -e DB_MODE=mysql -e DEN_DB_ENCRYPTION_KEY=ci-den-db-encryption-key-value-123456 -e BETTER_AUTH_SECRET=ci-better-auth-secret-value-123456 -e BETTER_AUTH_URL=http://127.0.0.1:8788 - image: openwork-den-web dockerfile: packaging/docker/Dockerfile.den-web port: 3005 health_path: /api/health smoke_env: "" - image: openwork-inference dockerfile: packaging/docker/Dockerfile.inference port: 8791 health_path: /health smoke_env: >- -e DB_MODE=mysql -e DATABASE_URL=mysql://smoke:smoke@127.0.0.1:3306/smoke -e DEN_DB_ENCRYPTION_KEY=ci-den-db-encryption-key-value-123456 # The gateway's own liveness lives at /__gw/health because /health is # proxied through to the user's instance. - image: openwork-den-gateway dockerfile: packaging/docker/Dockerfile.den-gateway port: 8788 health_path: /__gw/health smoke_env: >- -e DEN_API_BASE=http://127.0.0.1:9 -e DEN_GATEWAY_KEY=ci-den-gateway-key-value-123456 steps: - name: Checkout uses: actions/checkout@v6 - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to GHCR if: ${{ github.event_name == 'push' || inputs.push == true }} uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Docker metadata id: meta uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ matrix.image }} tags: | type=ref,event=tag type=semver,pattern={{version}} type=sha,prefix=sha- type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} type=raw,value=dev,enable=${{ github.ref == 'refs/heads/dev' }} labels: | org.opencontainers.image.title=${{ matrix.image }} org.opencontainers.image.source=https://github.com/${{ github.repository }} - name: Resolve Sentry source-map upload id: sentry_sourcemaps if: ${{ needs.artifact-version.outputs.publish == 'true' && github.event_name != 'pull_request' }} shell: bash env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_ORG: ${{ secrets.SENTRY_ORG }} SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} SENTRY_RELEASE: ${{ secrets.SENTRY_RELEASE }} run: | set -euo pipefail has_value() { [[ "$1" =~ [^[:space:]] ]] } den_api_upload=false den_web_upload=false if has_value "$SENTRY_AUTH_TOKEN" && has_value "$SENTRY_ORG" && has_value "$SENTRY_PROJECT" && has_value "$SENTRY_RELEASE"; then den_api_upload=true else echo "Den API Sentry source-map upload disabled: configure SENTRY_AUTH_TOKEN, SENTRY_ORG, SENTRY_PROJECT, and SENTRY_RELEASE to enable it." fi if has_value "$SENTRY_AUTH_TOKEN" && has_value "$SENTRY_ORG" && has_value "$SENTRY_PROJECT"; then den_web_upload=true else echo "Den Web Sentry source-map upload disabled: configure SENTRY_AUTH_TOKEN, SENTRY_ORG, and SENTRY_PROJECT to enable it." fi echo "den_api_upload=$den_api_upload" >> "$GITHUB_OUTPUT" echo "den_web_upload=$den_web_upload" >> "$GITHUB_OUTPUT" - name: Build and optionally push without Sentry source-map upload # Build every image except the ones whose Sentry-enabled step below owns # them. Stated as an exclusion so a newly added matrix entry builds by # default instead of silently skipping and failing the smoke step. if: ${{ !((matrix.image == 'openwork-den-api' && steps.sentry_sourcemaps.outputs.den_api_upload == 'true') || (matrix.image == 'openwork-den-web' && steps.sentry_sourcemaps.outputs.den_web_upload == 'true')) }} uses: docker/build-push-action@v6 with: context: . file: ${{ matrix.dockerfile }} platforms: ${{ (github.event_name == 'pull_request' || github.ref == 'refs/heads/dev') && 'linux/amd64' || 'linux/amd64,linux/arm64' }} load: ${{ github.event_name == 'pull_request' }} push: ${{ needs.artifact-version.outputs.publish == 'true' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha,scope=${{ matrix.image }} cache-to: type=gha,mode=max,scope=${{ matrix.image }} build-args: | ${{ matrix.image == 'openwork-den-api' && format('DEN_API_VERSION={0}', needs.artifact-version.outputs.chart_version || github.sha) || '' }} ${{ matrix.image == 'openwork-den-gateway' && format('DEN_GATEWAY_VERSION={0}', needs.artifact-version.outputs.chart_version || github.sha) || '' }} ${{ matrix.image == 'openwork-den-api' && 'DEN_UPLOAD_SENTRY_SOURCEMAPS=0' || '' }} ${{ matrix.image == 'openwork-den-web' && 'DEN_WEB_UPLOAD_SENTRY_SOURCEMAPS=0' || '' }} - name: Build and push with Sentry source-map upload if: ${{ (matrix.image == 'openwork-den-api' && steps.sentry_sourcemaps.outputs.den_api_upload == 'true') || (matrix.image == 'openwork-den-web' && steps.sentry_sourcemaps.outputs.den_web_upload == 'true') }} uses: docker/build-push-action@v6 with: context: . file: ${{ matrix.dockerfile }} platforms: ${{ (github.event_name == 'pull_request' || github.ref == 'refs/heads/dev') && 'linux/amd64' || 'linux/amd64,linux/arm64' }} push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha,scope=${{ matrix.image }} cache-to: type=gha,mode=max,scope=${{ matrix.image }} build-args: | ${{ matrix.image == 'openwork-den-api' && format('DEN_API_VERSION={0}', needs.artifact-version.outputs.chart_version || github.sha) || '' }} ${{ matrix.image == 'openwork-den-gateway' && format('DEN_GATEWAY_VERSION={0}', needs.artifact-version.outputs.chart_version || github.sha) || '' }} ${{ matrix.image == 'openwork-den-api' && 'DEN_UPLOAD_SENTRY_SOURCEMAPS=1' || '' }} ${{ matrix.image == 'openwork-den-web' && 'DEN_WEB_UPLOAD_SENTRY_SOURCEMAPS=1' || '' }} ${{ matrix.image == 'openwork-den-api' && format('DEN_SENTRY_UPLOAD_BUILD_ID={0}-{1}', github.run_id, github.run_attempt) || '' }} ${{ matrix.image == 'openwork-den-web' && format('DEN_WEB_SENTRY_UPLOAD_BUILD_ID={0}-{1}', github.run_id, github.run_attempt) || '' }} secrets: | sentry_auth_token=${{ secrets.SENTRY_AUTH_TOKEN }} sentry_org=${{ secrets.SENTRY_ORG }} sentry_project=${{ secrets.SENTRY_PROJECT }} sentry_url=${{ secrets.SENTRY_URL }} sentry_release=${{ secrets.SENTRY_RELEASE }} sentry_dist=${{ secrets.SENTRY_DIST }} - name: Assert Den DB migration assets if: ${{ github.event_name == 'pull_request' && matrix.image == 'openwork-den-api' }} shell: bash env: IMAGE_TAGS: ${{ steps.meta.outputs.tags }} run: | set -euo pipefail image_tag="${IMAGE_TAGS%%$'\n'*}" docker run --rm --entrypoint sh "$image_tag" -lc ' test -s /app/ee/packages/den-db/dist/scripts/bootstrap.js test -s /app/ee/packages/den-db/dist/current-schema.sql test -s /app/ee/packages/den-db/dist/drizzle/meta/_journal.json ' - name: Smoke health endpoint if: ${{ github.event_name == 'pull_request' }} shell: bash env: IMAGE_TAGS: ${{ steps.meta.outputs.tags }} run: | set -euo pipefail image_tag="${IMAGE_TAGS%%$'\n'*}" container_name="openwork-smoke-${{ matrix.image }}" mysql_container="" network_name="" # shellcheck disable=SC2329 # Invoked by the EXIT trap. cleanup() { status=$? trap - EXIT set +e if [ "$status" -ne 0 ]; then if docker inspect "$container_name" >/dev/null 2>&1; then echo "Smoke container logs:" docker logs "$container_name" || true fi if [ -n "$mysql_container" ] && docker inspect "$mysql_container" >/dev/null 2>&1; then echo "MySQL container logs:" docker logs "$mysql_container" || true fi fi docker rm -f "$container_name" >/dev/null 2>&1 || true if [ -n "$mysql_container" ]; then docker rm -f "$mysql_container" >/dev/null 2>&1 || true fi if [ -n "$network_name" ]; then docker network rm "$network_name" >/dev/null 2>&1 || true fi exit "$status" } trap cleanup EXIT if [ "${{ matrix.image }}" = "openwork-den-api" ]; then network_name="openwork-den-api-smoke" mysql_container="openwork-den-api-smoke-mysql" database_url="mysql://smoke:smoke@${mysql_container}:3306/smoke" docker network create "$network_name" >/dev/null docker run -d \ --name "$mysql_container" \ --network "$network_name" \ -e MYSQL_ROOT_PASSWORD=smoke-root-password \ -e MYSQL_DATABASE=smoke \ -e MYSQL_USER=smoke \ -e MYSQL_PASSWORD=smoke \ mysql:8.4@sha256:b3b90af2a6552ae30c266fdb7d5dd55f3afb72404bb78d37fe8a23eb857fd3fb >/dev/null mysql_ready="false" for _ in {1..30}; do if docker exec \ -e MYSQL_PWD=smoke-root-password \ "$mysql_container" \ mysqladmin ping --host=127.0.0.1 --user=root --silent >/dev/null 2>&1; then mysql_ready="true" echo "MySQL is ready" break fi sleep 2 done if [ "$mysql_ready" != "true" ]; then echo "MySQL did not become ready" >&2 exit 1 fi docker run --rm \ --network "$network_name" \ -e DATABASE_URL="$database_url" \ -e DEN_DB_ENCRYPTION_KEY=ci-den-db-encryption-key-value-123456 \ --entrypoint node \ "$image_tag" \ /app/ee/packages/den-db/dist/scripts/bootstrap.js container_id="$(docker run -d \ --name "$container_name" \ --network "$network_name" \ -P \ -e DATABASE_URL="$database_url" \ ${{ matrix.smoke_env }} \ "$image_tag")" else container_id="$(docker run -d --name "$container_name" -P ${{ matrix.smoke_env }} "$image_tag")" fi host_port="" for _ in {1..10}; do host_port="$(docker port "$container_id" "${{ matrix.port }}/tcp" 2>/dev/null | awk -F: 'NR == 1 { print $NF }')" if [ -n "$host_port" ]; then break fi sleep 1 done if [ -z "$host_port" ]; then docker logs "$container_id" || true exit 1 fi url="http://127.0.0.1:${host_port}${{ matrix.health_path }}" for _ in {1..30}; do if node -e "fetch(process.argv[1]).then((res) => process.exit(res.ok ? 0 : 1)).catch(() => process.exit(1))" "$url"; then echo "Health endpoint is ready: $url" exit 0 fi sleep 2 done docker logs "$container_id" || true exit 1 # The gateway is stateless (it serves the SPA and proxies), so dev CD is safe. # den-api and engine-snapshot dev CD are deliberate follow-ups because they # require migration and sandbox recycle handling. redeploy-den-gateway: needs: [artifact-version, ee-images] if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/dev' }} runs-on: ubuntu-latest steps: - name: Redeploy den-gateway on Render shell: bash env: RENDER_API_KEY: ${{ secrets.RENDER_API_KEY }} RENDER_DEN_GATEWAY_SERVICE_ID: ${{ secrets.RENDER_DEN_GATEWAY_SERVICE_ID }} run: | set -euo pipefail if [ -z "${RENDER_DEN_GATEWAY_SERVICE_ID:-}" ] || [ -z "${RENDER_API_KEY:-}" ]; then echo "::notice::Render gateway credentials unavailable; skipping redeploy..." echo "Render gateway redeploy skipped because credentials are unavailable." >> "$GITHUB_STEP_SUMMARY" exit 0 fi response="$(curl -s -w '\n%{http_code}' -X POST \ "https://api.render.com/v1/services/${RENDER_DEN_GATEWAY_SERVICE_ID}/deploys" \ -H "Authorization: Bearer $RENDER_API_KEY" \ -H "Content-Type: application/json" \ -d '{}')" code="$(printf '%s' "$response" | tail -1)" body="$(printf '%s' "$response" | sed '$d')" if [ "$code" -ge 400 ]; then echo "::error::POST /deploys returned HTTP $code: $body" exit 1 fi { echo echo "Triggered a den-gateway deploy so it pulls the fresh dev image." } >> "$GITHUB_STEP_SUMMARY"