diff --git a/.codecov.yml b/.codecov.yml index 490cadb098e..2fd52ae24ae 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -1,10 +1,49 @@ +codecov: + notify: + after_n_builds: 3 + coverage: status: project: - default: + backend: target: 80% informational: true + flags: + - backend + frontend: + target: 80% + informational: true + flags: + - frontend + e2e: + target: auto + informational: true + flags: + - e2e patch: - default: + backend: target: 80% informational: true + flags: + - backend + frontend: + target: 80% + informational: true + flags: + - frontend + e2e: + target: auto + informational: true + flags: + - e2e + +flags: + backend: + paths: + - openaev-api/src/main/java/ + frontend: + paths: + - openaev-front/src/ + e2e: + paths: + - openaev-front/src/ diff --git a/.github/actions/api-tests/action.yml b/.github/actions/api-tests/action.yml new file mode 100644 index 00000000000..956c2d11588 --- /dev/null +++ b/.github/actions/api-tests/action.yml @@ -0,0 +1,264 @@ +name: API Tests +description: > + Start service containers (PostgreSQL, Elasticsearch, RabbitMQ, MinIO), + download pre-compiled artifacts, and run API tests for a single shard. + Requires job-level env vars: EXPLICITLY_SHARDED_TESTS, + SPRING_DATASOURCE_URL, SPRING_DATASOURCE_USERNAME, + SPRING_DATASOURCE_PASSWORD, MINIO_ENDPOINT, MINIO_PORT, ENGINE_URL, + OPENBAS_RABBITMQ_HOSTNAME, GH_TOKEN. + +inputs: + includes: + description: 'Surefire test include patterns (multi-line) or "catchall"' + required: true + excludes: + description: 'Surefire test exclude patterns (multi-line, optional)' + required: false + default: '' + shard: + description: Shard number (used for artifact naming) + required: true + shard-name: + description: Shard display name + required: true + +runs: + using: composite + steps: + + - name: Start all services (non-blocking) + shell: bash + run: | + docker run -d --name pgsql \ + -p 5432:5432 \ + -e POSTGRES_USER=openaev \ + -e POSTGRES_PASSWORD=openaev \ + -e POSTGRES_DB=openaev \ + postgres:17-alpine \ + -c max_connections=1000 + docker run -d --name elasticsearch \ + -p 9200:9200 \ + -e discovery.type=single-node \ + -e xpack.security.enabled=false \ + -e "ES_JAVA_OPTS=-Xms2g -Xmx2g" \ + docker.elastic.co/elasticsearch/elasticsearch:8.18.3 + docker run -d --name rabbitmq \ + -p 5672:5672 \ + -p 15672:15672 \ + -e RABBITMQ_DEFAULT_USER=guest \ + -e RABBITMQ_DEFAULT_PASS=guest \ + rabbitmq:4.1-management + docker run -d --name minio \ + -p 9000:9000 \ + -e MINIO_ROOT_USER=minioadmin \ + -e MINIO_ROOT_PASSWORD=minioadmin \ + minio/minio:RELEASE.2025-06-13T11-33-47Z server /data + + echo "๐Ÿ” Verifying containers started..." + for name in pgsql elasticsearch rabbitmq minio; do + if ! docker inspect --format='{{.State.Running}}' "$name" 2>/dev/null | grep -q true; then + echo "โŒ Container '$name' failed to start" + docker logs "$name" 2>/dev/null || true + exit 1 + fi + done + echo "โœ… All containers started" + + - name: Set up Java 21 + uses: actions/setup-java@v5 + with: + java-version: "21" + distribution: temurin + cache: maven + + - name: Wait for and download compiled artifacts + shell: bash + run: | + echo "โณ Waiting for backend-compile artifacts (services booting in parallel)..." + for i in $(seq 1 120); do + HTTP_CODE=$(gh api "repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts" \ + --include 2>&1 | head -1 | awk '{print $2}' || echo "000") + NAMES=$(gh api "repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts" \ + --jq '[.artifacts[].name]' 2>/dev/null || echo "[]") + if [ "$HTTP_CODE" != "200" ] && [ "$HTTP_CODE" != "000" ]; then + echo "โš ๏ธ GitHub API returned HTTP $HTTP_CODE (attempt $i/120)" + fi + if echo "$NAMES" | grep -q '"api-build-output"'; then + echo "โœ… Artifacts available (after ~$((i * 5))s)" + break + fi + if [ "$i" -eq 120 ]; then + echo "โŒ Timed out waiting for artifacts (10 min)" + exit 1 + fi + echo " Attempt $i/120 โ€” not ready, retrying in 5s..." + sleep 5 + done + + echo "๐Ÿ“ฅ Downloading backend-compiled..." + if ! gh run download "${{ github.run_id }}" -n backend-compiled -D ~/.m2/repository/io/openaev/; then + echo "โŒ Failed to download backend-compiled artifact" + exit 1 + fi + echo "๐Ÿ“ฅ Downloading api-build-output..." + if ! gh run download "${{ github.run_id }}" -n api-build-output -D openaev-api/target; then + echo "โŒ Failed to download api-build-output artifact" + exit 1 + fi + + echo "๐Ÿ” Post-download: listing ~/.m2/repository/io/openaev/ (top 3 levels):" + find ~/.m2/repository/io/openaev/ -maxdepth 3 -type d 2>/dev/null | head -20 || echo " directory does not exist" + echo "---" + echo "๐Ÿ” Post-download: listing openaev-api/target/ (top 3 levels):" + find openaev-api/target/ -maxdepth 3 -type d 2>/dev/null | head -20 || echo " directory does not exist" + echo "---" + + - name: Verify downloaded artifacts + shell: bash + run: | + echo "๐Ÿ” Listing openaev-api/target/ (top 5 levels):" + find openaev-api/target/ -maxdepth 5 -type d 2>/dev/null | head -40 || echo " openaev-api/target/ does not exist" + echo "---" + + # โ”€โ”€ Fix double-nesting from gh run download โ”€โ”€ + # upload-artifact@v7 may store paths with the full prefix inside + # the zip, so gh run download -D openaev-api/target can produce + # openaev-api/target/openaev-api/target/classes/ instead of + # openaev-api/target/classes/. Detect and flatten. + if [ -d "openaev-api/target/openaev-api/target" ]; then + echo "โš ๏ธ Detected double-nested artifact path โ€” flattening..." + cp -rn openaev-api/target/openaev-api/target/* openaev-api/target/ 2>/dev/null || true + rm -rf openaev-api/target/openaev-api + echo "โœ… Flattened double-nested paths" + fi + + # Note: use "|| true" after "head" to prevent SIGPIPE from + # propagating under bash -eo pipefail (GitHub Actions default). + MVN_JARS=$(find ~/.m2/repository/io/openaev/ -name '*.jar' 2>/dev/null | head -5 || true) + if [ -z "$MVN_JARS" ]; then + echo "โŒ No Maven JARs found in ~/.m2/repository/io/openaev/" + echo "๐Ÿ” Listing ~/.m2/repository/io/openaev/ (top 4 levels):" + find ~/.m2/repository/io/openaev/ -maxdepth 4 2>/dev/null | head -30 || true + exit 1 + fi + MVN_JAR_COUNT=$(find ~/.m2/repository/io/openaev/ -name '*.jar' 2>/dev/null | wc -l || true) + echo "โœ… Maven artifacts found ($MVN_JAR_COUNT JARs)" + + if [ ! -d "openaev-api/target/classes" ]; then + echo "โŒ openaev-api/target/classes/ directory does not exist" + echo "๐Ÿ” Actual contents of openaev-api/target/:" + ls -la openaev-api/target/ 2>/dev/null || echo " target/ directory does not exist" + exit 1 + fi + echo "โœ… Compiled classes present" + + TEST_CLASS_COUNT=$(find openaev-api/target/test-classes -name '*Test.class' 2>/dev/null | wc -l || true) + echo " ๐Ÿ” Found $TEST_CLASS_COUNT *Test.class file(s) in test-classes/" + if [ "$TEST_CLASS_COUNT" -eq 0 ]; then + echo "โŒ No test classes found in openaev-api/target/test-classes/" + echo "๐Ÿ” Actual contents of openaev-api/target/test-classes/ (top 6 levels):" + find openaev-api/target/test-classes -maxdepth 6 2>/dev/null | head -40 || echo " test-classes/ does not exist" + echo "๐Ÿ” Sample .class files (any name):" + find openaev-api/target/test-classes -name '*.class' 2>/dev/null | head -10 || echo " none" + exit 1 + fi + echo "โœ… Test classes found ($TEST_CLASS_COUNT *Test.class files)" + + - name: Wait for services + shell: bash + run: | + echo "โณ Waiting for PostgreSQL..." + for i in $(seq 1 30); do + docker exec pgsql pg_isready -U openaev && break + sleep 1 + done + docker exec pgsql pg_isready -U openaev || { echo "โŒ PostgreSQL failed to start"; exit 1; } + + echo "โณ Waiting for Elasticsearch..." + for i in $(seq 1 30); do + curl -sf http://localhost:9200/_cluster/health && break + sleep 2 + done + curl -sf http://localhost:9200/_cluster/health || { echo "โŒ Elasticsearch failed to start"; exit 1; } + + echo "โณ Waiting for RabbitMQ..." + for i in $(seq 1 30); do + docker exec rabbitmq rabbitmq-diagnostics -q check_running 2>/dev/null && break + sleep 2 + done + docker exec rabbitmq rabbitmq-diagnostics -q check_running || { echo "โŒ RabbitMQ failed to start"; exit 1; } + + echo "โณ Waiting for MinIO..." + for i in $(seq 1 30); do + curl -sf http://localhost:9000/minio/health/live && break + sleep 1 + done + curl -sf http://localhost:9000/minio/health/live || { echo "โŒ MinIO failed to start"; exit 1; } + + echo "โœ… All services ready" + + - name: Prepare test suite + id: suite + shell: bash + env: + SHARD_INCLUDES: ${{ inputs.includes }} + SHARD_EXCLUDES: ${{ inputs.excludes }} + run: | + if [ "$SHARD_INCLUDES" = "catchall" ]; then + echo "$EXPLICITLY_SHARDED_TESTS" > /tmp/surefire-excludes.txt + echo "surefire_arg=-Dsurefire.excludesFile=/tmp/surefire-excludes.txt" >> "$GITHUB_OUTPUT" + echo "๐Ÿ”„ Catch-all shard: running all tests NOT assigned to shards 1โ€“3" + cat /tmp/surefire-excludes.txt + else + echo "$SHARD_INCLUDES" > /tmp/surefire-includes.txt + ARGS="-Dsurefire.includesFile=/tmp/surefire-includes.txt" + if [ -n "$SHARD_EXCLUDES" ]; then + echo "$SHARD_EXCLUDES" > /tmp/surefire-excludes.txt + ARGS="$ARGS -Dsurefire.excludesFile=/tmp/surefire-excludes.txt" + echo "๐Ÿ“‹ Include patterns:" + cat /tmp/surefire-includes.txt + echo "๐Ÿšซ Exclude patterns:" + cat /tmp/surefire-excludes.txt + else + echo "๐Ÿ“‹ Include patterns:" + cat /tmp/surefire-includes.txt + fi + echo "surefire_arg=$ARGS" >> "$GITHUB_OUTPUT" + fi + + - name: Run API tests (shard ${{ inputs.shard }} โ€” ${{ inputs.shard-name }}) + id: run-tests + shell: bash + # Run the 'test' lifecycle phase so that JaCoCo's prepare-agent + # execution (bound to 'initialize' in pom.xml) fires through the + # normal lifecycle and properly sets the argLine property BEFORE + # Surefire reads it. Compilation is skipped because the classes + # are already downloaded from the backend-compile job. + run: >- + mvn -B -ntp -pl openaev-api test + -Dmaven.compiler.skip=true + ${{ steps.suite.outputs.surefire_arg }} + - name: Verify JaCoCo exec data + if: ${{ !cancelled() }} + shell: bash + run: | + EXEC_FILE="openaev-api/target/jacoco.exec" + if [ -f "$EXEC_FILE" ]; then + SIZE=$(stat --format=%s "$EXEC_FILE" 2>/dev/null || stat -f%z "$EXEC_FILE" 2>/dev/null) + echo "โœ… $EXEC_FILE exists ($SIZE bytes)" + else + echo "โŒ $EXEC_FILE not found after test run" + echo " Test step outcome: ${{ steps.run-tests.outcome }}" + echo " Contents of openaev-api/target/:" + ls -la openaev-api/target/ 2>/dev/null || echo " directory does not exist" + echo " Surefire reports:" + ls -la openaev-api/target/surefire-reports/ 2>/dev/null || echo " no surefire-reports/" + exit 1 + fi + - name: Upload JaCoCo exec data + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: jacoco-exec-shard-${{ inputs.shard }} + path: openaev-api/target/jacoco.exec + if-no-files-found: error diff --git a/.github/actions/backend-compile/action.yml b/.github/actions/backend-compile/action.yml new file mode 100644 index 00000000000..040fb40e7ad --- /dev/null +++ b/.github/actions/backend-compile/action.yml @@ -0,0 +1,31 @@ +name: Backend Compile +description: Compile all Maven modules and upload compiled artifacts + +runs: + using: composite + steps: + - name: Set up Java 21 + uses: actions/setup-java@v5 + with: + java-version: "21" + distribution: temurin + cache: maven + - name: Compile and install all modules (skip tests) + run: >- + mvn -B -ntp -T 1C install -DskipTests + -Dspring-boot.repackage.skip=true + -Djacoco.skip=true + shell: bash + - name: Upload compiled Maven artifacts + uses: actions/upload-artifact@v7 + with: + name: backend-compiled + path: ~/.m2/repository/io/openaev/ + - name: Upload API build output + uses: actions/upload-artifact@v7 + with: + name: api-build-output + path: | + openaev-api/target/classes/ + openaev-api/target/test-classes/ + diff --git a/.github/actions/backend-package-musl/action.yml b/.github/actions/backend-package-musl/action.yml new file mode 100644 index 00000000000..7c720995145 --- /dev/null +++ b/.github/actions/backend-package-musl/action.yml @@ -0,0 +1,81 @@ +name: Backend Package (musl) +description: > + Download pre-compiled artifacts, package the backend fat JAR inside an + Alpine/musl container, verify it, and upload the artifact. + The calling job MUST set container: maven:3.9.14-eclipse-temurin-21-alpine. + +outputs: + jar-size: + description: Size of the packaged JAR in bytes + value: ${{ steps.verify-jar.outputs.jar_size }} + +runs: + using: composite + steps: + - name: Install Alpine dependencies + run: apk add --no-cache git + shell: sh + - name: Download frontend build artifact + uses: actions/download-artifact@v8 + with: + name: frontend-build + path: openaev-front/builder/prod/build + - name: Download compiled Maven artifacts + uses: actions/download-artifact@v8 + with: + name: backend-compiled + path: /root/.m2/repository/io/openaev/ + - name: Download release assets + uses: actions/download-artifact@v8 + with: + name: release-assets + path: openaev-api/src/main/resources + - name: Verify downloaded artifacts + run: | + # Verify frontend build + if [ ! -d "openaev-front/builder/prod/build" ] || [ -z "$(ls -A openaev-front/builder/prod/build 2>/dev/null)" ]; then + echo "โŒ Frontend build artifacts missing or empty" + exit 1 + fi + echo "โœ… Frontend build present" + + # Verify Maven artifacts + MVN_JARS=$(find /root/.m2/repository/io/openaev/ -name '*.jar' 2>/dev/null | head -5) + if [ -z "$MVN_JARS" ]; then + echo "โŒ No Maven JARs found in /root/.m2/repository/io/openaev/" + exit 1 + fi + echo "โœ… Maven artifacts found" + + # Verify release assets + if [ ! -d "openaev-api/src/main/resources/catalog" ]; then + echo "โŒ Release assets (catalog) missing" + exit 1 + fi + echo "โœ… Release assets present" + shell: sh + - name: Package backend JAR (musl) + run: mvn -B -ntp -q -pl openaev-api package -DskipTests + shell: sh + - name: Verify packaged JAR (musl) + id: verify-jar + run: | + if [ ! -f "openaev-api/target/openaev-api.jar" ]; then + echo "โŒ openaev-api.jar (musl) was not produced" + ls -la openaev-api/target/ 2>/dev/null || echo " target/ directory does not exist" + exit 1 + fi + JAR_SIZE=$(stat -c%s openaev-api/target/openaev-api.jar 2>/dev/null || wc -c < openaev-api/target/openaev-api.jar) + if [ "$JAR_SIZE" -lt 1000 ] 2>/dev/null; then + echo "โŒ openaev-api.jar (musl) is suspiciously small ($JAR_SIZE bytes)" + exit 1 + fi + echo "โœ… openaev-api.jar (musl) produced ($JAR_SIZE bytes)" + echo "jar_size=$JAR_SIZE" >> "$GITHUB_OUTPUT" + shell: sh + - name: Upload musl JAR artifact + uses: actions/upload-artifact@v7 + with: + name: openaev-api-jar-musl + path: openaev-api/target/openaev-api.jar + diff --git a/.github/actions/backend-package/action.yml b/.github/actions/backend-package/action.yml new file mode 100644 index 00000000000..c3a6c81b08d --- /dev/null +++ b/.github/actions/backend-package/action.yml @@ -0,0 +1,84 @@ +name: Backend Package +description: > + Download pre-compiled artifacts, package the backend fat JAR (glibc), + verify it, and upload the artifact. + +inputs: + download-release-assets: + description: Whether to download the release-assets artifact + required: false + default: 'false' + +outputs: + jar-size: + description: Size of the packaged JAR in bytes + value: ${{ steps.verify-jar.outputs.jar_size }} + +runs: + using: composite + steps: + - name: Download frontend build artifact + uses: actions/download-artifact@v8 + with: + name: frontend-build + path: openaev-front/builder/prod/build + - name: Download compiled Maven artifacts + uses: actions/download-artifact@v8 + with: + name: backend-compiled + path: ~/.m2/repository/io/openaev/ + - name: Download release assets (agents, implants, catalog) + if: ${{ inputs.download-release-assets == 'true' }} + uses: actions/download-artifact@v8 + with: + name: release-assets + path: openaev-api/src/main/resources + - name: Set up Java 21 + uses: actions/setup-java@v5 + with: + java-version: "21" + distribution: temurin + cache: maven + - name: Verify downloaded artifacts + run: | + # Verify frontend build + if [ ! -d "openaev-front/builder/prod/build" ] || [ -z "$(ls -A openaev-front/builder/prod/build 2>/dev/null)" ]; then + echo "โŒ Frontend build artifacts missing or empty" + ls -la openaev-front/builder/prod/ 2>/dev/null || echo " directory does not exist" + exit 1 + fi + echo "โœ… Frontend build present ($(find openaev-front/builder/prod/build -type f | wc -l) files)" + + # Verify Maven artifacts + MVN_JARS=$(find ~/.m2/repository/io/openaev/ -name '*.jar' 2>/dev/null | head -5) + if [ -z "$MVN_JARS" ]; then + echo "โŒ No Maven JARs found in ~/.m2/repository/io/openaev/" + exit 1 + fi + echo "โœ… Maven artifacts found" + shell: bash + - name: Package backend JAR + run: mvn -B -ntp -q -pl openaev-api package -DskipTests + shell: bash + - name: Verify packaged JAR + id: verify-jar + run: | + if [ ! -f "openaev-api/target/openaev-api.jar" ]; then + echo "โŒ openaev-api.jar was not produced" + ls -la openaev-api/target/ 2>/dev/null || echo " target/ directory does not exist" + exit 1 + fi + JAR_SIZE=$(stat --format=%s openaev-api/target/openaev-api.jar 2>/dev/null || stat -f%z openaev-api/target/openaev-api.jar 2>/dev/null) + if [ "$JAR_SIZE" -lt 1000 ] 2>/dev/null; then + echo "โŒ openaev-api.jar is suspiciously small ($JAR_SIZE bytes)" + exit 1 + fi + echo "โœ… openaev-api.jar produced ($JAR_SIZE bytes)" + echo "jar_size=$JAR_SIZE" >> "$GITHUB_OUTPUT" + shell: bash + - name: Upload backend JAR artifact + uses: actions/upload-artifact@v7 + with: + name: openaev-api-jar + path: openaev-api/target/openaev-api.jar + diff --git a/.github/actions/docker-build-push/action.yml b/.github/actions/docker-build-push/action.yml new file mode 100644 index 00000000000..6227f875aa7 --- /dev/null +++ b/.github/actions/docker-build-push/action.yml @@ -0,0 +1,283 @@ +name: Docker Build & Validate (Multi-arch) +description: > + Build multi-arch Docker images (linux/amd64 + linux/arm64) for standard and + optionally UBI9 variants. Uses buildx + QEMU to produce OCI archives + containing both platforms, validates the amd64 variant locally, and uploads + the archives as artifacts. This action NEVER pushes to any external + registry. The calling job MUST run actions/checkout before this action. + +inputs: + tags: + description: > + Docker image tags (newline or comma-separated). Passed directly to + docker/build-push-action. + required: true + labels: + description: Docker image labels (from docker/metadata-action) + required: false + default: "" + build-ubi9: + description: > + When 'true', also build a UBI9-based Docker image from Dockerfile_ubi9_ga. + required: false + default: "false" + ubi9-tags: + description: > + Docker image tags for the UBI9 variant (newline or comma-separated). + Required when build-ubi9 is 'true'. + required: false + default: "" + ubi9-labels: + description: Docker image labels for the UBI9 variant + required: false + default: "" + +outputs: + digest: + description: Docker image digest (standard image) + value: ${{ steps.docker-build.outputs.digest }} + ubi9-digest: + description: Docker image digest (UBI9 image) + value: ${{ steps.docker-build-ubi9.outputs.digest }} + image_size_mb: + description: Docker image size in MB + value: ${{ steps.docker-validate.outputs.image_size_mb }} + image_warning: + description: Whether image size is suspiciously small + value: ${{ steps.docker-validate.outputs.image_warning }} + jar_in_image: + description: Whether openaev-api.jar was found inside the image + value: ${{ steps.docker-validate.outputs.jar_in_image }} + java_version: + description: Java version inside the image + value: ${{ steps.docker-validate.outputs.java_version }} + layer_count: + description: Number of image layers + value: ${{ steps.docker-validate.outputs.layer_count }} + ubi9_image_size_mb: + description: UBI9 Docker image size in MB + value: ${{ steps.docker-validate-ubi9.outputs.image_size_mb }} + ubi9_jar_in_image: + description: Whether openaev-api.jar was found inside UBI9 image + value: ${{ steps.docker-validate-ubi9.outputs.jar_in_image }} + ubi9_java_version: + description: Java version inside UBI9 image + value: ${{ steps.docker-validate-ubi9.outputs.java_version }} + +runs: + using: composite + steps: + - name: Validate inputs + run: | + if [ "${{ inputs.build-ubi9 }}" = "true" ] && [ -z "${{ inputs.ubi9-tags }}" ]; then + echo "โŒ ubi9-tags is required when build-ubi9 is 'true'" + exit 1 + fi + shell: bash + + - name: Download backend JAR artifact + uses: actions/download-artifact@v8 + with: + name: openaev-api-jar + path: openaev-build + + - name: Verify backend JAR + run: | + if [ ! -f "openaev-build/openaev-api.jar" ]; then + echo "โŒ openaev-api.jar not found in openaev-build/" + ls -la openaev-build/ 2>/dev/null || echo " directory does not exist" + exit 1 + fi + JAR_SIZE=$(stat --format=%s openaev-build/openaev-api.jar 2>/dev/null || stat -f%z openaev-build/openaev-api.jar 2>/dev/null) + if [ "$JAR_SIZE" -lt 1000 ] 2>/dev/null; then + echo "โŒ openaev-api.jar is suspiciously small ($JAR_SIZE bytes)" + exit 1 + fi + echo "โœ… openaev-api.jar present for Docker build ($JAR_SIZE bytes)" + shell: bash + + - name: Set up QEMU (for arm64 emulation) + uses: docker/setup-qemu-action@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + # โ”€โ”€ Standard image (multi-arch) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + - name: Build standard image (multi-arch OCI archive) + id: docker-build + uses: docker/build-push-action@v7 + with: + context: . + file: ./Dockerfile_ga + platforms: linux/amd64,linux/arm64 + push: false + outputs: type=oci,dest=openaev-image.tar + tags: ${{ inputs.tags }} + labels: ${{ inputs.labels }} + + - name: Compress standard OCI archive + run: | + gzip openaev-image.tar + TAR_SIZE=$(stat --format=%s openaev-image.tar.gz 2>/dev/null || stat -f%z openaev-image.tar.gz 2>/dev/null) + TAR_SIZE_MB=$(awk "BEGIN {printf \"%.1f\", $TAR_SIZE / 1048576}") + echo "โœ… Standard multi-arch OCI archive (${TAR_SIZE_MB} MB compressed)" + shell: bash + + - name: Validate standard Docker image (amd64) + id: docker-validate + run: | + # Load only the amd64 variant into the local daemon for validation + gunzip -k openaev-image.tar.gz + skopeo copy --override-os linux --override-arch amd64 \ + oci-archive:openaev-image.tar \ + docker-daemon:openaev-validate:amd64 + rm -f openaev-image.tar + IMAGE="openaev-validate:amd64" + + # Verify multi-arch manifest contains both platforms + echo "โ”€โ”€ Multi-arch manifest โ”€โ”€" + skopeo inspect --raw oci-archive:openaev-image.tar.gz \ + | python3 -c " + import json, sys + idx = json.load(sys.stdin) + manifests = idx.get('manifests', []) + for m in manifests: + p = m.get('platform', {}) + print(f\" {p.get('os','?')}/{p.get('architecture','?')}\") + " || echo " (could not inspect manifest)" + + # Check image size + IMAGE_SIZE=$(docker image inspect "$IMAGE" --format='{{.Size}}') + IMAGE_SIZE_MB=$(awk "BEGIN {printf \"%.1f\", $IMAGE_SIZE / 1048576}") + echo "image_size=$IMAGE_SIZE" >> "$GITHUB_OUTPUT" + echo "image_size_mb=$IMAGE_SIZE_MB" >> "$GITHUB_OUTPUT" + echo " Image size: ${IMAGE_SIZE_MB} MB" + + if [ "$IMAGE_SIZE" -lt 209715200 ]; then + echo "โš ๏ธ Docker image is only ${IMAGE_SIZE_MB} MB โ€” suspiciously small" + echo "image_warning=true" >> "$GITHUB_OUTPUT" + else + echo "โœ… Docker image size looks healthy" + echo "image_warning=false" >> "$GITHUB_OUTPUT" + fi + + # Verify JAR is inside the image + JAR_CHECK_ERR=$(mktemp) + JAR_IN_IMAGE=$(docker run --rm --entrypoint ls "$IMAGE" -la /opt/openaev-api.jar 2>"$JAR_CHECK_ERR") || true + if [ -z "$JAR_IN_IMAGE" ]; then + echo "โŒ openaev-api.jar NOT found inside Docker image at /opt/openaev-api.jar" + if [ -s "$JAR_CHECK_ERR" ]; then + echo " Error: $(cat "$JAR_CHECK_ERR")" + fi + echo " Listing /opt/ contents for diagnostics:" + docker run --rm --entrypoint ls "$IMAGE" -la /opt/ 2>/dev/null \ + | sed 's/^/ /' || echo " (could not list /opt/)" + echo "jar_in_image=false" >> "$GITHUB_OUTPUT" + else + echo "โœ… openaev-api.jar found inside Docker image" + echo " $JAR_IN_IMAGE" + echo "jar_in_image=true" >> "$GITHUB_OUTPUT" + fi + rm -f "$JAR_CHECK_ERR" + + # Verify Java is available + JAVA_VERSION=$(docker run --rm --entrypoint java "$IMAGE" -version 2>&1 | head -1) || true + if [ -z "$JAVA_VERSION" ]; then + echo "โŒ Java runtime not found inside Docker image" + echo "java_version=NOT_FOUND" >> "$GITHUB_OUTPUT" + else + echo " Java: $JAVA_VERSION" + echo "java_version=$JAVA_VERSION" >> "$GITHUB_OUTPUT" + fi + + # Count layers + LAYER_COUNT=$(docker image inspect "$IMAGE" --format='{{len .RootFS.Layers}}') + echo " Layers: $LAYER_COUNT" + echo "layer_count=$LAYER_COUNT" >> "$GITHUB_OUTPUT" + shell: bash + + - name: Upload standard Docker image artifact + uses: actions/upload-artifact@v7 + with: + name: openaev-image + path: openaev-image.tar.gz + retention-days: 1 + + # โ”€โ”€ UBI9 image (multi-arch) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + - name: Build UBI9 image (multi-arch OCI archive) + id: docker-build-ubi9 + if: ${{ inputs.build-ubi9 == 'true' }} + uses: docker/build-push-action@v7 + with: + context: . + file: ./Dockerfile_ubi9_ga + platforms: linux/amd64,linux/arm64 + push: false + outputs: type=oci,dest=openaev-ubi9-image.tar + tags: ${{ inputs.ubi9-tags }} + labels: ${{ inputs.ubi9-labels }} + + - name: Compress UBI9 OCI archive + if: ${{ inputs.build-ubi9 == 'true' }} + run: | + gzip openaev-ubi9-image.tar + TAR_SIZE=$(stat --format=%s openaev-ubi9-image.tar.gz 2>/dev/null || stat -f%z openaev-ubi9-image.tar.gz 2>/dev/null) + TAR_SIZE_MB=$(awk "BEGIN {printf \"%.1f\", $TAR_SIZE / 1048576}") + echo "โœ… UBI9 multi-arch OCI archive (${TAR_SIZE_MB} MB compressed)" + shell: bash + + - name: Validate UBI9 Docker image (amd64) + id: docker-validate-ubi9 + if: ${{ inputs.build-ubi9 == 'true' }} + run: | + # Load only the amd64 variant into the local daemon for validation + gunzip -k openaev-ubi9-image.tar.gz + skopeo copy --override-os linux --override-arch amd64 \ + oci-archive:openaev-ubi9-image.tar \ + docker-daemon:openaev-ubi9-validate:amd64 + rm -f openaev-ubi9-image.tar + IMAGE="openaev-ubi9-validate:amd64" + + # Check image size + IMAGE_SIZE=$(docker image inspect "$IMAGE" --format='{{.Size}}') + IMAGE_SIZE_MB=$(awk "BEGIN {printf \"%.1f\", $IMAGE_SIZE / 1048576}") + echo "image_size_mb=$IMAGE_SIZE_MB" >> "$GITHUB_OUTPUT" + echo " UBI9 Image size: ${IMAGE_SIZE_MB} MB" + + if [ "$IMAGE_SIZE" -lt 209715200 ]; then + echo "โš ๏ธ UBI9 Docker image is only ${IMAGE_SIZE_MB} MB โ€” suspiciously small" + else + echo "โœ… UBI9 Docker image size looks healthy" + fi + + # Verify JAR is inside the image + JAR_IN_IMAGE=$(docker run --rm --entrypoint ls "$IMAGE" -la /opt/openaev/openaev-api.jar 2>/dev/null) || true + if [ -z "$JAR_IN_IMAGE" ]; then + echo "โŒ openaev-api.jar NOT found inside UBI9 Docker image" + echo " Listing /opt/openaev/ contents:" + docker run --rm --entrypoint ls "$IMAGE" -la /opt/openaev/ 2>/dev/null \ + | sed 's/^/ /' || echo " (could not list /opt/openaev/)" + echo "jar_in_image=false" >> "$GITHUB_OUTPUT" + exit 1 + fi + echo "โœ… openaev-api.jar found inside UBI9 Docker image" + echo "jar_in_image=true" >> "$GITHUB_OUTPUT" + + # Verify Java is available + JAVA_VERSION=$(docker run --rm --entrypoint java "$IMAGE" -version 2>&1 | head -1) || true + if [ -n "$JAVA_VERSION" ]; then + echo " Java: $JAVA_VERSION" + echo "java_version=$JAVA_VERSION" >> "$GITHUB_OUTPUT" + else + echo "โš ๏ธ Could not determine Java version" + echo "java_version=NOT_FOUND" >> "$GITHUB_OUTPUT" + fi + shell: bash + + - name: Upload UBI9 Docker image artifact + if: ${{ inputs.build-ubi9 == 'true' }} + uses: actions/upload-artifact@v7 + with: + name: openaev-ubi9-image + path: openaev-ubi9-image.tar.gz + retention-days: 1 diff --git a/.github/actions/e2e-tests/action.yml b/.github/actions/e2e-tests/action.yml new file mode 100644 index 00000000000..22d6f0a1f72 --- /dev/null +++ b/.github/actions/e2e-tests/action.yml @@ -0,0 +1,226 @@ +name: E2E & API Types +description: > + Start service containers (PostgreSQL, Elasticsearch, RabbitMQ, MinIO), + download and run the backend JAR, run Playwright E2E tests, and verify + API types are up to date. + Requires job-level env vars: SPRING_DATASOURCE_URL, + SPRING_DATASOURCE_USERNAME, SPRING_DATASOURCE_PASSWORD, + MINIO_ENDPOINT, MINIO_PORT, MINIO_ACCESS_KEY, MINIO_ACCESS_SECRET, + ENGINE_URL, OPENBAS_RABBITMQ_HOSTNAME, OPENAEV_ADMIN_EMAIL, + OPENAEV_ADMIN_PASSWORD, OPENAEV_ADMIN_TOKEN, + OPENAEV_ADMIN_ENCRYPTION_KEY, OPENAEV_ADMIN_ENCRYPTION_SALT, + SPRING_PROFILES_ACTIVE, GH_TOKEN. + +runs: + using: composite + steps: + + - name: Start all services (non-blocking) + shell: bash + run: | + docker run -d --name pgsql \ + -p 5432:5432 \ + -e POSTGRES_USER=openaev \ + -e POSTGRES_PASSWORD=openaev \ + -e POSTGRES_DB=openaev \ + postgres:17-alpine \ + -c max_connections=1000 + docker run -d --name elasticsearch \ + -p 9200:9200 \ + -e discovery.type=single-node \ + -e xpack.security.enabled=false \ + -e "ES_JAVA_OPTS=-Xms2g -Xmx2g" \ + docker.elastic.co/elasticsearch/elasticsearch:8.18.3 + docker run -d --name rabbitmq \ + -p 5672:5672 \ + -p 15672:15672 \ + -e RABBITMQ_DEFAULT_USER=guest \ + -e RABBITMQ_DEFAULT_PASS=guest \ + rabbitmq:4.1-management + docker run -d --name minio \ + -p 9000:9000 \ + -e MINIO_ROOT_USER=minioadmin \ + -e MINIO_ROOT_PASSWORD=minioadmin \ + minio/minio:RELEASE.2025-06-13T11-33-47Z server /data + + echo "๐Ÿ” Verifying containers started..." + for name in pgsql elasticsearch rabbitmq minio; do + if ! docker inspect --format='{{.State.Running}}' "$name" 2>/dev/null | grep -q true; then + echo "โŒ Container '$name' failed to start" + docker logs "$name" 2>/dev/null || true + exit 1 + fi + done + echo "โœ… All containers started" + + - name: Set up Java 21 + uses: actions/setup-java@v5 + with: + java-version: "21" + distribution: temurin + - name: Set up Node.js 22.11 + uses: actions/setup-node@v6 + with: + node-version: "22.11.0" + - name: Install corepack + run: npm install -g corepack + shell: bash + - name: Get Yarn cache directory + id: yarn-cache-dir + run: echo "dir=$(yarn config get cacheFolder)" >> "$GITHUB_OUTPUT" + shell: bash + working-directory: openaev-front + - name: Cache Yarn dependencies + uses: actions/cache@v5 + with: + path: ${{ steps.yarn-cache-dir.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('openaev-front/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + - name: Install frontend dependencies + run: yarn install --immutable + shell: bash + working-directory: openaev-front + - name: Cache Playwright browsers + id: playwright-cache + uses: actions/cache@v5 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('openaev-front/yarn.lock') }} + - name: Install Playwright browsers (overlaps with artifact wait) + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: yarn playwright install --with-deps chromium + shell: bash + working-directory: openaev-front + - name: Install Playwright system dependencies (cache hit) + if: steps.playwright-cache.outputs.cache-hit == 'true' + run: yarn playwright install-deps chromium + shell: bash + working-directory: openaev-front + + - name: Wait for and download backend JAR artifact + shell: bash + run: | + echo "โณ Waiting for backend-package artifact (services booting in parallel)..." + for i in $(seq 1 180); do + HTTP_CODE=$(gh api "repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts" \ + --include 2>&1 | head -1 | awk '{print $2}' || echo "000") + NAMES=$(gh api "repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts" \ + --jq '[.artifacts[].name]' 2>/dev/null || echo "[]") + if [ "$HTTP_CODE" != "200" ] && [ "$HTTP_CODE" != "000" ]; then + echo "โš ๏ธ GitHub API returned HTTP $HTTP_CODE (attempt $i/180)" + fi + if echo "$NAMES" | grep -q '"openaev-api-jar"'; then + echo "โœ… Artifact available (after ~$((i * 5))s)" + break + fi + if [ "$i" -eq 180 ]; then + echo "โŒ Timed out waiting for artifact (15 min)" + exit 1 + fi + echo " Attempt $i/180 โ€” not ready, retrying in 5s..." + sleep 5 + done + + echo "๐Ÿ“ฅ Downloading openaev-api-jar..." + if ! gh run download "${{ github.run_id }}" -n openaev-api-jar -D openaev-api/target; then + echo "โŒ Failed to download openaev-api-jar artifact" + exit 1 + fi + + - name: Verify backend JAR + shell: bash + run: | + if [ ! -f "openaev-api/target/openaev-api.jar" ]; then + echo "โŒ openaev-api.jar not found after download" + ls -la openaev-api/target/ 2>/dev/null || echo " target/ directory does not exist" + exit 1 + fi + JAR_SIZE=$(stat --format=%s openaev-api/target/openaev-api.jar 2>/dev/null || stat -f%z openaev-api/target/openaev-api.jar 2>/dev/null) + if [ "$JAR_SIZE" -lt 1000 ] 2>/dev/null; then + echo "โŒ openaev-api.jar is suspiciously small ($JAR_SIZE bytes)" + exit 1 + fi + echo "โœ… openaev-api.jar present ($JAR_SIZE bytes)" + + - name: Wait for services + shell: bash + run: | + echo "โณ Waiting for PostgreSQL..." + for i in $(seq 1 30); do + docker exec pgsql pg_isready -U openaev && break + sleep 1 + done + docker exec pgsql pg_isready -U openaev || { echo "โŒ PostgreSQL failed to start"; exit 1; } + + echo "โณ Waiting for Elasticsearch..." + for i in $(seq 1 30); do + curl -sf http://localhost:9200/_cluster/health && break + sleep 2 + done + curl -sf http://localhost:9200/_cluster/health || { echo "โŒ Elasticsearch failed to start"; exit 1; } + + echo "โณ Waiting for RabbitMQ..." + for i in $(seq 1 30); do + docker exec rabbitmq rabbitmq-diagnostics -q check_running 2>/dev/null && break + sleep 2 + done + docker exec rabbitmq rabbitmq-diagnostics -q check_running || { echo "โŒ RabbitMQ failed to start"; exit 1; } + + echo "โณ Waiting for MinIO..." + for i in $(seq 1 30); do + curl -sf http://localhost:9000/minio/health/live && break + sleep 1 + done + curl -sf http://localhost:9000/minio/health/live || { echo "โŒ MinIO failed to start"; exit 1; } + + echo "โœ… All services ready" + + - name: Start application + run: java -jar openaev-api/target/openaev-api.jar & + shell: bash + - name: Wait for application to be ready + shell: bash + run: | + echo "Waiting for application on port 8080..." + timeout 180 bash -c ' + until curl -sf -o /dev/null -w "%{http_code}" http://localhost:8080 | grep -q "200"; do + sleep 2 + done + ' || { + echo "โŒ Application failed to become ready within 180s" + echo " Last HTTP response:" + curl -s -o /dev/null -w " HTTP %{http_code}\n" http://localhost:8080 2>/dev/null || echo " (no response)" + exit 1 + } + echo "โœ… Application is ready (HTTP 200)" + - name: Run E2E tests + run: yarn test:e2e + shell: bash + working-directory: openaev-front + env: + APP_URL: http://localhost:8080 + E2E_COVERAGE: "true" + - name: Verify API types are up to date + shell: bash + working-directory: openaev-front + run: | + API_URL=http://localhost:8080 yarn generate-types-from-api + if git diff --name-only | grep -q 'src/utils/api-types.d.ts'; then + echo "Detected changes in src/utils/api-types.d.ts:"; + git diff -- src/utils/api-types.d.ts || git diff -- openaev-front/src/utils/api-types.d.ts; + echo "โš ๏ธ Forgot to generate types! Please run 'yarn run generate-types-from-api' before committing."; exit 1; + fi + - name: Upload Playwright report + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: playwright-report + path: openaev-front/test-results/ + - name: Upload E2E coverage report + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: playwright-coverage + path: openaev-front/test-results/coverage/ + diff --git a/.github/actions/frontend-build/action.yml b/.github/actions/frontend-build/action.yml new file mode 100644 index 00000000000..811b0b373de --- /dev/null +++ b/.github/actions/frontend-build/action.yml @@ -0,0 +1,38 @@ +name: Frontend Build +description: Install frontend dependencies, build production bundle, and upload artifact + +runs: + using: composite + steps: + - name: Set up Node.js 22.11 + uses: actions/setup-node@v6 + with: + node-version: "22.11.0" + - name: Install corepack + run: npm install -g corepack + shell: bash + - name: Get Yarn cache directory + id: yarn-cache-dir + run: echo "dir=$(yarn config get cacheFolder)" >> "$GITHUB_OUTPUT" + shell: bash + working-directory: openaev-front + - name: Cache Yarn dependencies + uses: actions/cache@v5 + with: + path: ${{ steps.yarn-cache-dir.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('openaev-front/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + - name: Install dependencies + run: yarn install --immutable + shell: bash + working-directory: openaev-front + - name: Build frontend + run: yarn build + shell: bash + working-directory: openaev-front + - name: Upload frontend build artifact + uses: actions/upload-artifact@v7 + with: + name: frontend-build + path: openaev-front/builder/prod/build/ diff --git a/.github/actions/frontend-quality/action.yml b/.github/actions/frontend-quality/action.yml new file mode 100644 index 00000000000..7fc8496bedf --- /dev/null +++ b/.github/actions/frontend-quality/action.yml @@ -0,0 +1,56 @@ +name: Frontend Quality & Unit Tests +description: > + Run TypeScript checks, ESLint, i18n validation, and Vitest unit tests. + Uploads coverage report as an artifact. + +runs: + using: composite + steps: + - name: Set up Node.js 22.11 + uses: actions/setup-node@v6 + with: + node-version: "22.11.0" + - name: Install corepack + run: npm install -g corepack + shell: bash + - name: Get Yarn cache directory + id: yarn-cache-dir + run: echo "dir=$(yarn config get cacheFolder)" >> "$GITHUB_OUTPUT" + shell: bash + working-directory: openaev-front + - name: Cache Yarn dependencies + uses: actions/cache@v5 + with: + path: ${{ steps.yarn-cache-dir.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('openaev-front/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + - name: Install dependencies + run: yarn install --immutable + shell: bash + working-directory: openaev-front + - name: TypeScript check + run: yarn check-ts + shell: bash + working-directory: openaev-front + - name: Run ESLint + run: yarn lint + shell: bash + working-directory: openaev-front + - name: Check i18n translations + run: yarn i18n-checker + shell: bash + working-directory: openaev-front + - name: Run unit tests + run: yarn test -- --coverage + shell: bash + working-directory: openaev-front + env: + NODE_OPTIONS: --max_old_space_size=8192 + - name: Upload frontend coverage report + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: vitest-coverage + path: openaev-front/coverage/ + diff --git a/.github/actions/jfrog-package/action.yml b/.github/actions/jfrog-package/action.yml new file mode 100644 index 00000000000..cf90498327a --- /dev/null +++ b/.github/actions/jfrog-package/action.yml @@ -0,0 +1,202 @@ +name: JFrog Package +description: > + Download glibc and musl JAR artifacts, package them as tar.gz archives with + application.properties, validate archive contents, and either upload to JFrog + (production) or save as GitHub artifacts (dry-run). + + When a version is provided, the primary archives are version-named (e.g. + openaev-2.4.0.tar.gz) with date-named copies for backward compatibility. This + mirrors the logic in release/promote.yml's jfrog-upload job โ€” keep both in + sync. + +inputs: + version: + description: > + Semver version string (e.g. "2.4.0"). When set, archives are named + openaev-VERSION.tar.gz (primary) with openaev-DATE.tar.gz copies for + backward compatibility. When empty, only date-based naming is used. + required: false + default: "" + dry-run: + description: > + When 'true', upload archives as GitHub artifacts instead of JFrog. + required: false + default: "false" + jfrog-user: + description: JFrog username (required when dry-run is 'false') + required: false + default: "" + jfrog-token: + description: JFrog token (required when dry-run is 'false') + required: false + default: "" + +outputs: + date_stamp: + description: Date stamp used for archive naming (YYYYMMDD) + value: ${{ steps.package.outputs.date_stamp }} + version: + description: Version used for primary archive naming (empty if date-only) + value: ${{ steps.package.outputs.version }} + glibc_archive_size_mb: + description: Size of glibc archive in MB + value: ${{ steps.package.outputs.glibc_archive_size_mb }} + musl_archive_size_mb: + description: Size of musl archive in MB + value: ${{ steps.package.outputs.musl_archive_size_mb }} + +runs: + using: composite + steps: + - name: Download glibc JAR + uses: actions/download-artifact@v8 + with: + name: openaev-api-jar + path: glibc-jar + + - name: Download musl JAR + uses: actions/download-artifact@v8 + with: + name: openaev-api-jar-musl + path: musl-jar + + - name: Download API build output (for application.properties) + uses: actions/download-artifact@v8 + with: + name: api-build-output + path: build-output + + - name: Package and validate tar.gz archives + id: package + run: | + set -euo pipefail + DATE_STAMP="$(date '+%Y%m%d')" + VERSION="${{ inputs.version }}" + echo "date_stamp=${DATE_STAMP}" >> "$GITHUB_OUTPUT" + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + + for f in glibc-jar/openaev-api.jar musl-jar/openaev-api.jar build-output/classes/application.properties; do + if [ ! -f "$f" ]; then + echo "โŒ Required file missing: $f" + exit 1 + fi + done + echo "โœ… All required files present" + + mkdir -p release-glibc/openaev + cp glibc-jar/openaev-api.jar release-glibc/openaev/ + cp build-output/classes/application.properties release-glibc/openaev/ + + mkdir -p release-musl/openaev + cp musl-jar/openaev-api.jar release-musl/openaev/ + cp build-output/classes/application.properties release-musl/openaev/ + + # โ”€โ”€ Determine primary archive name โ”€โ”€ + # When version is set: primary is version-named, date is a compat copy. + # When version is empty: primary is date-named only. + if [ -n "$VERSION" ]; then + PRIMARY_LABEL="$VERSION" + tar -C release-glibc -zcvf "openaev-${VERSION}.tar.gz" openaev + tar -C release-musl -zcvf "openaev-${VERSION}_musl.tar.gz" openaev + # Date-based copies for backward compatibility + cp "openaev-${VERSION}.tar.gz" "openaev-${DATE_STAMP}.tar.gz" + cp "openaev-${VERSION}_musl.tar.gz" "openaev-${DATE_STAMP}_musl.tar.gz" + else + PRIMARY_LABEL="$DATE_STAMP" + tar -C release-glibc -zcvf "openaev-${DATE_STAMP}.tar.gz" openaev + tar -C release-musl -zcvf "openaev-${DATE_STAMP}_musl.tar.gz" openaev + fi + + for variant in "" "_musl"; do + ARCHIVE="openaev-${PRIMARY_LABEL}${variant}.tar.gz" + if [ ! -s "$ARCHIVE" ]; then + echo "โŒ Archive is empty or missing: $ARCHIVE" + exit 1 + fi + ARCHIVE_SIZE=$(stat --format=%s "$ARCHIVE") + ARCHIVE_SIZE_MB=$(awk "BEGIN {printf \"%.1f\", $ARCHIVE_SIZE / 1048576}") + echo " ${ARCHIVE}: ${ARCHIVE_SIZE_MB} MB" + + CONTENTS=$(tar -tzf "$ARCHIVE") + echo " Contents:" + echo "$CONTENTS" | sed 's/^/ /' + + EXPECTED_FILES=("openaev/openaev-api.jar" "openaev/application.properties") + for expected in "${EXPECTED_FILES[@]}"; do + if ! echo "$CONTENTS" | grep -q "$expected"; then + echo " โŒ Missing expected file in archive: $expected" + exit 1 + fi + done + echo " โœ… Archive valid" + done + + GLIBC_SIZE=$(stat --format=%s "openaev-${PRIMARY_LABEL}.tar.gz") + MUSL_SIZE=$(stat --format=%s "openaev-${PRIMARY_LABEL}_musl.tar.gz") + GLIBC_SIZE_MB=$(awk "BEGIN {printf \"%.1f\", $GLIBC_SIZE / 1048576}") + MUSL_SIZE_MB=$(awk "BEGIN {printf \"%.1f\", $MUSL_SIZE / 1048576}") + echo "glibc_archive_size_mb=${GLIBC_SIZE_MB}" >> "$GITHUB_OUTPUT" + echo "musl_archive_size_mb=${MUSL_SIZE_MB}" >> "$GITHUB_OUTPUT" + shell: bash + + - name: Upload to JFrog + if: ${{ inputs.dry-run != 'true' }} + run: | + set -euo pipefail + DATE_STAMP="${{ steps.package.outputs.date_stamp }}" + VERSION="${{ steps.package.outputs.version }}" + JFROG_BASE="https://filigran.jfrog.io/artifactory/openaev" + + upload_to_jfrog() { + local LOCAL_FILE="$1" + local REMOTE_NAME="$2" + HTTP_CODE=$(curl --retry 3 -w "%{http_code}" -o /tmp/jfrog-resp.txt \ + -u "${{ inputs.jfrog-user }}:${{ inputs.jfrog-token }}" \ + -T "$LOCAL_FILE" \ + "${JFROG_BASE}/${REMOTE_NAME}" || true) + if [ "$HTTP_CODE" != "201" ] && [ "$HTTP_CODE" != "200" ]; then + echo "โŒ JFrog upload failed for ${REMOTE_NAME} (HTTP $HTTP_CODE)" + cat /tmp/jfrog-resp.txt 2>/dev/null || true + exit 1 + fi + echo "โœ… Uploaded ${REMOTE_NAME} (HTTP $HTTP_CODE)" + } + + if [ -n "$VERSION" ]; then + # Upload version-named archives (primary) + upload_to_jfrog "openaev-${VERSION}.tar.gz" "openaev-${VERSION}.tar.gz" + upload_to_jfrog "openaev-${VERSION}_musl.tar.gz" "openaev-${VERSION}_musl.tar.gz" + # Upload date-named archives (backward compatibility) + upload_to_jfrog "openaev-${DATE_STAMP}.tar.gz" "openaev-${DATE_STAMP}.tar.gz" + upload_to_jfrog "openaev-${DATE_STAMP}_musl.tar.gz" "openaev-${DATE_STAMP}_musl.tar.gz" + else + upload_to_jfrog "openaev-${DATE_STAMP}.tar.gz" "openaev-${DATE_STAMP}.tar.gz" + upload_to_jfrog "openaev-${DATE_STAMP}_musl.tar.gz" "openaev-${DATE_STAMP}_musl.tar.gz" + fi + shell: bash + + - name: Log simulated JFrog targets (dry-run) + if: ${{ inputs.dry-run == 'true' }} + run: | + DATE_STAMP="${{ steps.package.outputs.date_stamp }}" + VERSION="${{ steps.package.outputs.version }}" + GLIBC_MB="${{ steps.package.outputs.glibc_archive_size_mb }}" + MUSL_MB="${{ steps.package.outputs.musl_archive_size_mb }}" + echo "" + echo "โ”€โ”€ JFrog upload targets (NOT uploaded in dry run) โ”€โ”€" + if [ -n "$VERSION" ]; then + echo " Primary: openaev-${VERSION}.tar.gz / openaev-${VERSION}_musl.tar.gz (${GLIBC_MB} / ${MUSL_MB} MB)" + echo " Compat: openaev-${DATE_STAMP}.tar.gz / openaev-${DATE_STAMP}_musl.tar.gz" + else + echo " openaev-${DATE_STAMP}.tar.gz (${GLIBC_MB} MB)" + echo " openaev-${DATE_STAMP}_musl.tar.gz (${MUSL_MB} MB)" + fi + shell: bash + + - name: Upload dry-run archives as GitHub artifacts + if: ${{ inputs.dry-run == 'true' }} + uses: actions/upload-artifact@v7 + with: + name: jfrog-dry-run-archives + path: | + openaev-*.tar.gz diff --git a/.github/actions/spotless-check/action.yml b/.github/actions/spotless-check/action.yml new file mode 100644 index 00000000000..7a8357da197 --- /dev/null +++ b/.github/actions/spotless-check/action.yml @@ -0,0 +1,16 @@ +name: Spotless Check +description: Check Java code formatting with Spotless (Google Java Format) + +runs: + using: composite + steps: + - name: Set up Java 21 + uses: actions/setup-java@v5 + with: + java-version: "21" + distribution: temurin + cache: maven + - name: Check formatting + run: mvn -B -ntp spotless:check + shell: bash + diff --git a/.github/workflows/_quality-gates.yml b/.github/workflows/_quality-gates.yml new file mode 100644 index 00000000000..7f20fa6e96b --- /dev/null +++ b/.github/workflows/_quality-gates.yml @@ -0,0 +1,275 @@ +name: "Quality Gates" + +# Reusable workflow containing the shared quality gate jobs: +# - Frontend quality & unit tests +# - API tests (4-shard matrix) +# - E2E tests & API types +# - Coverage merge, threshold check & optional Codecov upload +# +# Used by core-ci.yml and core-release-dry-run.yml to avoid duplication. +# When adding a new test shard or changing env vars, you only need to +# update this file. + +on: + workflow_call: + inputs: + upload-codecov: + description: "Upload coverage reports to Codecov and save artifacts" + type: boolean + default: true + +permissions: + contents: read + actions: read + +jobs: + + frontend-quality: + name: "๐Ÿงช Frontend Quality & Unit Tests" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/frontend-quality + + api-tests: + name: "๐Ÿงช API Tests (${{ matrix.shard_name }})" + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + # โ”€โ”€ Four workers: main, rest-1, rest-2, everything else โ”€โ”€ + - shard: 1 + shard_name: main + includes: |- + io/openaev/api/**/*Test.java + io/openaev/integration/**/*Test.java + io/openaev/injects/**/*Test.java + io/openaev/opencti/**/*Test.java + io/openaev/service/**/*Test.java + - shard: 2 + shard_name: rest-1 + # Packages aโ€“h: root-level rest tests + sub-packages + # starting with letters a through h. + # NOTE: Surefire uses Ant-style patterns โ€” [a-h] bracket + # expressions are NOT supported; list packages explicitly. + includes: |- + io/openaev/rest/*Test.java + io/openaev/rest/asset_group/**/*Test.java + io/openaev/rest/attack_pattern/**/*Test.java + io/openaev/rest/custom_dashboard/**/*Test.java + io/openaev/rest/dashboard/**/*Test.java + io/openaev/rest/exercise/**/*Test.java + - shard: 3 + shard_name: rest-2 + # Everything under rest/ NOT assigned to shard 2. + # New rest sub-packages are automatically picked up here. + includes: |- + io/openaev/rest/**/*Test.java + excludes: |- + io/openaev/rest/*Test.java + io/openaev/rest/asset_group/**/*Test.java + io/openaev/rest/attack_pattern/**/*Test.java + io/openaev/rest/custom_dashboard/**/*Test.java + io/openaev/rest/dashboard/**/*Test.java + io/openaev/rest/exercise/**/*Test.java + - shard: 4 + shard_name: remaining + # Catch-all: runs every test NOT assigned to shards 1โ€“3. + # Any new test package is executed here automatically. + includes: catchall + env: + # โ”€โ”€ Union of all explicitly-assigned shard patterns (shards 1โ€“3) โ”€โ”€ + # The catch-all shard uses this as an *excludes* list, so any test + # added in a brand-new package is automatically executed without + # having to touch this workflow file. + # When you add a pattern to a shard above, mirror it here. + EXPLICITLY_SHARDED_TESTS: |- + io/openaev/api/**/*Test.java + io/openaev/integration/**/*Test.java + io/openaev/injects/**/*Test.java + io/openaev/opencti/**/*Test.java + io/openaev/service/**/*Test.java + io/openaev/rest/**/*Test.java + SPRING_DATASOURCE_URL: jdbc:postgresql://localhost:5432/openaev + SPRING_DATASOURCE_USERNAME: openaev + SPRING_DATASOURCE_PASSWORD: openaev + MINIO_ENDPOINT: localhost + MINIO_PORT: 9000 + ENGINE_URL: http://localhost:9200 + OPENBAS_RABBITMQ_HOSTNAME: localhost + GH_TOKEN: ${{ github.token }} + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/api-tests + with: + includes: ${{ matrix.includes }} + excludes: ${{ matrix.excludes }} + shard: ${{ matrix.shard }} + shard-name: ${{ matrix.shard_name }} + + e2e-tests: + name: "๐Ÿงช E2E & API Types" + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + SPRING_DATASOURCE_URL: jdbc:postgresql://localhost:5432/openaev + SPRING_DATASOURCE_USERNAME: openaev + SPRING_DATASOURCE_PASSWORD: openaev + MINIO_ENDPOINT: localhost + MINIO_PORT: 9000 + MINIO_ACCESS_KEY: minioadmin + MINIO_ACCESS_SECRET: minioadmin + ENGINE_URL: http://localhost:9200 + OPENBAS_RABBITMQ_HOSTNAME: localhost + OPENAEV_ADMIN_EMAIL: admin@openaev.io + OPENAEV_ADMIN_PASSWORD: admin + OPENAEV_ADMIN_TOKEN: 0d17ce9a-f3a8-4c6d-9721-c98dc3dc023f + OPENAEV_ADMIN_ENCRYPTION_KEY: ThisIsMyUltraSecureEncryptionKey + OPENAEV_ADMIN_ENCRYPTION_SALT: ilikesaltyfoodnomnom + SPRING_PROFILES_ACTIVE: ci + GH_TOKEN: ${{ github.token }} + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/e2e-tests + + codecov: + name: "๐Ÿ“Š Coverage Merge & Upload" + needs: [ api-tests, frontend-quality, e2e-tests ] + # Always run so partial coverage (e.g. backend+frontend) is still + # uploaded even when one upstream job fails or is cancelled. + if: ${{ !cancelled() }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Set up Java 21 + uses: actions/setup-java@v5 + with: + java-version: "21" + distribution: temurin + cache: maven + + # โ”€โ”€ Backend coverage: merge JaCoCo shards, report & check โ”€โ”€โ”€โ”€โ”€โ”€ + - name: Download compiled Maven artifacts + if: needs.api-tests.result == 'success' || needs.api-tests.result == 'failure' + uses: actions/download-artifact@v8 + with: + name: backend-compiled + path: ~/.m2/repository/io/openaev/ + continue-on-error: true + + - name: Download API build output + if: needs.api-tests.result == 'success' || needs.api-tests.result == 'failure' + uses: actions/download-artifact@v8 + with: + name: api-build-output + path: openaev-api/target + continue-on-error: true + + - name: Download all JaCoCo exec shards + if: needs.api-tests.result == 'success' || needs.api-tests.result == 'failure' + uses: actions/download-artifact@v8 + with: + pattern: jacoco-exec-shard-* + path: jacoco-exec + continue-on-error: true + + - name: Merge JaCoCo exec files + id: jacoco-merge + if: needs.api-tests.result == 'success' || needs.api-tests.result == 'failure' + run: | + echo "๐Ÿ“ฆ Merging JaCoCo exec files from all shards..." + EXEC_FILES=$(find jacoco-exec -name 'jacoco.exec' 2>/dev/null) + if [ -z "$EXEC_FILES" ]; then + echo "โš ๏ธ No JaCoCo exec files found โ€” all test shards may have failed" + echo " Contents of jacoco-exec/:" + ls -laR jacoco-exec/ 2>/dev/null || echo " directory does not exist" + exit 1 + fi + SHARD_COUNT=$(echo "$EXEC_FILES" | wc -l) + echo " Found $SHARD_COUNT shard exec file(s)" + cat jacoco-exec/jacoco-exec-shard-*/jacoco.exec > openaev-api/target/jacoco.exec + if [ ! -s "openaev-api/target/jacoco.exec" ]; then + echo "โŒ Merged jacoco.exec is empty" + exit 1 + fi + echo "โœ… Merged $SHARD_COUNT shard(s) โ†’ $(wc -c < openaev-api/target/jacoco.exec) bytes" + + - name: Coverage report & threshold check + if: steps.jacoco-merge.outcome == 'success' + run: mvn -B -ntp -pl openaev-api jacoco:report jacoco:check || echo "โš ๏ธ Coverage + threshold check โš ๏ธ" + + - name: Upload API coverage report + if: ${{ !cancelled() && steps.jacoco-merge.outcome == 'success' && + inputs.upload-codecov }} + uses: actions/upload-artifact@v7 + with: + name: jacoco-api + path: openaev-api/target/site/jacoco/jacoco.xml + if-no-files-found: warn + + # โ”€โ”€ Download frontend / E2E coverage artifacts โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + - name: Download vitest coverage report + if: ${{ inputs.upload-codecov && needs.frontend-quality.result == 'success' }} + uses: actions/download-artifact@v8 + with: + name: vitest-coverage + path: coverage/frontend + continue-on-error: true + + - name: Download playwright coverage report + if: ${{ inputs.upload-codecov && needs.e2e-tests.result == 'success' }} + uses: actions/download-artifact@v8 + with: + name: playwright-coverage + path: coverage/e2e + continue-on-error: true + + # โ”€โ”€ Detect which reports are available โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + - name: Check available coverage files + if: ${{ inputs.upload-codecov }} + id: check + run: | + echo "๐Ÿ“Š Coverage files found:" + find coverage/ -type f 2>/dev/null || echo " (none)" + find openaev-api/target/site/jacoco/ -type f 2>/dev/null || echo " (no backend coverage)" + + has() { [ -f "$1" ] && echo "true" || echo "false"; } + + echo "has_backend=$(has openaev-api/target/site/jacoco/jacoco.xml)" >> "$GITHUB_OUTPUT" + echo "has_frontend=$(has coverage/frontend/lcov.info)" >> "$GITHUB_OUTPUT" + echo "has_e2e=$(has coverage/e2e/lcov.info)" >> "$GITHUB_OUTPUT" + + # โ”€โ”€ Upload to Codecov โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + - name: Upload backend coverage to Codecov + if: ${{ inputs.upload-codecov && steps.check.outputs.has_backend == 'true' }} + uses: codecov/codecov-action@v6 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: openaev-api/target/site/jacoco/jacoco.xml + flags: backend + disable_search: true + fail_ci_if_error: false + + - name: Upload frontend coverage to Codecov + if: ${{ inputs.upload-codecov && steps.check.outputs.has_frontend == 'true' }} + uses: codecov/codecov-action@v6 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: coverage/frontend/lcov.info + flags: frontend + disable_search: true + fail_ci_if_error: false + + - name: Upload E2E coverage to Codecov + if: ${{ inputs.upload-codecov && steps.check.outputs.has_e2e == 'true' }} + uses: codecov/codecov-action@v6 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: coverage/e2e/lcov.info + flags: e2e + disable_search: true + fail_ci_if_error: false diff --git a/.github/workflows/core-ci.yml b/.github/workflows/core-ci.yml new file mode 100644 index 00000000000..39f5065cd6a --- /dev/null +++ b/.github/workflows/core-ci.yml @@ -0,0 +1,339 @@ +name: "[Core] CI" + +on: + push: + branches: [ master, release/current ] + tags: + - "[0-9]*.[0-9]*.[0-9]*" + pull_request: + branches: [ master, release/current ] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + actions: read + +jobs: + + spotless-check: + name: "๐Ÿ” Spotless Check" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/spotless-check + + backend-compile: + name: "๐Ÿ”จ Backend Compile" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/backend-compile + + frontend-build: + name: "๐ŸŽจ Frontend Build" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/frontend-build + + # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + # Quality gates (frontend quality, API tests, E2E tests, coverage) + # Defined in _quality-gates.yml to avoid duplication with + # core-release-dry-run.yml. + # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + quality-gates: + uses: ./.github/workflows/_quality-gates.yml + secrets: inherit + + backend-package: + name: "๐Ÿ“ฆ Backend Package (glibc)" + needs: + - frontend-build + - backend-compile + - prepare-release-assets + timeout-minutes: 15 + # Run even when prepare-release-assets is skipped (PRs). + # Note: !cancelled() is required so the `if` is evaluated when a + # dependency is skipped (success() alone returns false for skipped + # dependencies, which would cascade-skip e2e-tests). Unlike always(), + # !cancelled() still respects manual workflow cancellation. + if: >- + ${{ + !cancelled() && + needs.frontend-build.result == 'success' && + needs.backend-compile.result == 'success' && + (needs.prepare-release-assets.result == 'success' || needs.prepare-release-assets.result == 'skipped') + }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/backend-package + with: + download-release-assets: ${{ needs.prepare-release-assets.result == 'success' }} + + # Download agent/implant binaries and patch catalog version + prepare-release-assets: + name: "๐Ÿ“ฆ Prepare Release Assets" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 # full history needed for version tags + + - name: Compute versions + id: versions + run: | + if [[ "$GITHUB_REF" == refs/tags/* ]]; then + TAG="${GITHUB_REF#refs/tags/}" + echo "binary_version=${TAG}" >> "$GITHUB_OUTPUT" + echo "catalog_version=${TAG}" >> "$GITHUB_OUTPUT" + elif [[ "$GITHUB_REF" == refs/heads/release/current ]]; then + echo "binary_version=prerelease" >> "$GITHUB_OUTPUT" + echo "catalog_version=prerelease" >> "$GITHUB_OUTPUT" + else + echo "binary_version=latest" >> "$GITHUB_OUTPUT" + echo "catalog_version=rolling" >> "$GITHUB_OUTPUT" + fi + + - name: Download agent & implant binaries + run: | + BINARY_VERSION="${{ steps.versions.outputs.binary_version }}" + + # Use latest git tag as local filename for non-release builds + if [[ "${{ steps.versions.outputs.catalog_version }}" == "rolling" || "${{ steps.versions.outputs.catalog_version }}" == "prerelease" ]]; then + LOCAL_VERSION="$(git describe --tags --abbrev=0 2>/dev/null || echo "$BINARY_VERSION")" + else + LOCAL_VERSION="$BINARY_VERSION" + fi + + chmod +x scripts/download-binaries.sh + if ! bash scripts/download-binaries.sh "$BINARY_VERSION" "$LOCAL_VERSION"; then + echo "โŒ download-binaries.sh failed" + exit 1 + fi + + # Verify at least some binaries were downloaded + AGENT_COUNT=$(find openaev-api/src/main/resources/agents/ -type f 2>/dev/null | wc -l) + IMPLANT_COUNT=$(find openaev-api/src/main/resources/implants/ -type f 2>/dev/null | wc -l) + echo "๐Ÿ“ฆ Downloaded $AGENT_COUNT agent(s) and $IMPLANT_COUNT implant(s)" + if [ "$AGENT_COUNT" -eq 0 ] && [ "$IMPLANT_COUNT" -eq 0 ]; then + echo "โš ๏ธ Warning: No agent or implant binaries were downloaded" + fi + + - name: Patch catalog version + run: | + CATALOG_FILE=openaev-api/src/main/resources/catalog/catalog-integrators.json + CATALOG_VERSION="${{ steps.versions.outputs.catalog_version }}" + + if [ ! -f "$CATALOG_FILE" ]; then + echo "โŒ Catalog file not found: $CATALOG_FILE" + ls -la openaev-api/src/main/resources/catalog/ 2>/dev/null || echo " catalog/ directory does not exist" + exit 1 + fi + + if ! jq --arg ver "$CATALOG_VERSION" \ + '.contracts[].container_version = $ver | .version = $ver' \ + "$CATALOG_FILE" > catalog.tmp; then + echo "โŒ jq failed to patch catalog file (invalid JSON?)" + exit 1 + fi + + if [ ! -s "catalog.tmp" ]; then + echo "โŒ jq produced empty output" + exit 1 + fi + + # Validate the patched file is still valid JSON + if ! jq empty catalog.tmp 2>/dev/null; then + echo "โŒ Patched catalog file is not valid JSON" + exit 1 + fi + + mv catalog.tmp "$CATALOG_FILE" + echo "โœ… Catalog version patched to '$CATALOG_VERSION'" + + - name: Upload release assets + uses: actions/upload-artifact@v7 + with: + name: release-assets + path: | + openaev-api/src/main/resources/agents/ + openaev-api/src/main/resources/implants/ + openaev-api/src/main/resources/catalog/ + + # Alpine/musl JAR build for Alpine-based Docker images + backend-package-musl: + name: "๐Ÿ“ฆ Backend Package (musl)" + needs: [ frontend-build, backend-compile, prepare-release-assets ] + runs-on: ubuntu-latest + container: + image: maven:3.9.14-eclipse-temurin-21-alpine + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/backend-package-musl + + # Build all Docker images locally (standard + UBI9) + docker-build: + name: "๐Ÿณ Docker Build (all images)" + needs: + - backend-package + - prepare-release-assets + timeout-minutes: 20 + if: >- + ${{ + !cancelled() && + needs.backend-package.result == 'success' && + (needs.prepare-release-assets.result == 'success' || needs.prepare-release-assets.result == 'skipped') + }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/docker-build-push + with: + build-ubi9: "true" + tags: openaev:ci-${{ github.run_id }} + ubi9-tags: openaev-ubi9:ci-${{ github.run_id }} + + # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + # Final sanity check โ€” download every artifact and Docker image, + # verify presence and reasonable sizes. + # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + sanity-check: + name: "๐Ÿฉบ Artifact & Image Sanity Check" + needs: + - frontend-build + - backend-compile + - backend-package + - backend-package-musl + - docker-build + - prepare-release-assets + if: >- + ${{ + !cancelled() && + needs.frontend-build.result == 'success' && + needs.backend-compile.result == 'success' && + needs.backend-package.result == 'success' && + needs.backend-package-musl.result == 'success' && + needs.docker-build.result == 'success' + }} + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Download all artifacts + uses: actions/download-artifact@v8 + with: + path: /tmp/artifacts + + - name: Sanity check โ€” artifacts & images + run: | + set -euo pipefail + + PASS=0 + FAIL=0 + WARN=0 + + # โ”€โ”€ Helper: check a single file โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + # Usage: check_file