From 847fd91da1535b2fe645e2da5e9bedcf19f36088 Mon Sep 17 00:00:00 2001 From: sojeebakhtar Date: Wed, 9 Sep 2026 15:51:00 +0600 Subject: [PATCH 1/6] ci(dev): build to Docker Hub and deploy to the self-hosted dev VM Replace the dev pipeline's Azure ACR build + AKS GitOps path with a matrix build that pushes dev--{api,worker} to Docker Hub and a deploy job that recreates the two containers on the blocks-infra VM. Staging and production workflows are unchanged. Notes: - vars.env has no trailing newline, so it is loaded as `{ cat vars.env; echo; } >> $GITHUB_ENV`; a bare `cat >>` would splice the last variable onto the next value written to the env file. - The deploy job logs in to Docker Hub itself rather than relying on the runner user's ~/.docker/config.json, since the images are private. - `--profile infra` is only there to make the compose project valid (the app services declare depends_on mongodb). With --no-deps and explicit service names it starts nothing else, and mongodb-seed now sits in an opt-in [seed] profile so a deploy cannot re-seed the database. - A verification step asserts both containers are running on the new SHA, so a crash-looping deploy fails the job instead of reporting success. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci-dev.yml | 498 ++++++++++------------------------- 1 file changed, 139 insertions(+), 359 deletions(-) diff --git a/.github/workflows/ci-dev.yml b/.github/workflows/ci-dev.yml index eb351405..614eaf3d 100644 --- a/.github/workflows/ci-dev.yml +++ b/.github/workflows/ci-dev.yml @@ -1,374 +1,154 @@ -name: CI/CD - Dev Environment +name: CI - Dev on: push: - branches: - - dev - pull_request: - branches: - - dev - types: - - opened - - reopened - - synchronize - -env: - # ======================================== - # Environment-Specific Configuration - # (Different for each environment) - # ======================================== - ENVIRONMENT: "dev" # dev, stg, uat, prod - CLUSTER_NAME: "aks-blocks-dev" # TODO: Set your dev cluster name - - # ======================================== - # Quality Checks - # ======================================== - RUN_TESTS: "true" # Usually disabled in dev for speed - RUN_SONARQUBE: "true" # Enable for testing the new dotnet-coverage integration - RUN_SCA_SCAN: "true" # Enable SCA Scan for dependency analysis - - # ======================================== - # Tag Stategy for Build and GitOps - # ======================================== - TAG_STRATEGY: "commit" # Options: "both" (default), "semantic", "commit" - - # ======================================== - # Shared Configuration Loading - # ======================================== - LOAD_CUSTOM_VARS: "true" # Load shared config from .github/variables/vars.env - # Set to 'false' only if you don't have vars.env + branches: [dev] concurrency: - group: ${{ github.ref_name }} + group: ci-dev-${{ github.ref }} cancel-in-progress: true + +env: + ENVIRONMENT: dev + jobs: - # Initialize configuration - initialization: + build-and-push: runs-on: ubuntu-latest - outputs: - cluster_name: ${{ steps.config.outputs.cluster_name }} - environment: ${{ steps.config.outputs.environment }} - load_custom_vars: ${{ steps.config.outputs.load_custom_vars }} - sca_project_server: ${{ steps.config.outputs.sca_project_server }} - sca_project_client: ${{ steps.config.outputs.sca_project_client }} - project_version: ${{ steps.config.outputs.project_version }} - run_tests: ${{ steps.config.outputs.run_tests }} - run_sonarqube: ${{ steps.config.outputs.run_sonarqube }} - run_sca_scan: ${{ steps.config.outputs.run_sca_scan }} - service_name: ${{ steps.config.outputs.service_name }} # e.g., your service name - service_type: ${{ steps.config.outputs.service_type }} # e.g., webclient, webservice, winservice - sonarqube_host: ${{ steps.config.outputs.sonarqube_host }} # e.g., https://code.selise.biz - sonar_project_key_server: ${{ steps.config.outputs.sonar_project_key_server }} - sonar_project_key_client: ${{ steps.config.outputs.sonar_project_key_client }} - sonar_organization: ${{ steps.config.outputs.sonar_organization }} # e.g., your SonarQube org - solution_name: ${{ steps.config.outputs.solution_name }} # e.g., YourSolution.sln for .NET - tag_strategy: ${{ steps.config.outputs.tag_strategy }} # e.g., commit, semantic - working_directory: ${{ steps.config.outputs.working_directory }} # e.g., ./src. Used for SonarQube - version: ${{ steps.config.outputs.version }} # Version suffix based on environment - client_dockerfile_path: ${{ steps.config.outputs.client_dockerfile_path }} # Path to Dockerfile - worker_dockerfile_path: ${{ steps.config.outputs.worker_dockerfile_path }} # Path to Window/Console service Dockerfile - dependency_track_host: ${{ steps.config.outputs.dependency_track_host }} # e.g., api-dt.seliseblocks.com - dependency_track_frontend_url: ${{ steps.config.outputs.dependency_track_frontend_url }} # e.g., sca.seliseblocks.com - dotnet_version: ${{ steps.config.outputs.dotnet_version }} - has_submodules: ${{ steps.config.outputs.has_submodules }} + timeout-minutes: 45 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - component: api + dockerfile: DOCKERFILE_CLIENT + - component: worker + dockerfile: DOCKERFILE_WORKER + name: build-and-push (${{ matrix.component }}) steps: - - uses: actions/checkout@v4 - - # Load shared variables first (if enabled) - - name: Load Shared Configuration - if: env.LOAD_CUSTOM_VARS == 'true' - uses: SELISEdigitalplatforms/blocks-inventory/.github/actions/setvars@main - with: - varFilePath: ./.github/variables/ - - - name: Set Configuration Outputs - id: config + - name: Checkout + uses: actions/checkout@v4 + + # vars.env supplies REPO_NAME, DOCKERFILE_CLIENT, DOCKERFILE_WORKER. + # The trailing `echo` is required: the vars.env files do not end with a + # newline, so a bare `cat >>` would splice the last variable onto + # whatever GitHub appends to $GITHUB_ENV next. + - name: Load repo variables (vars.env) run: | - # From workflow env (environment-specific) - echo "run_tests=${{ env.RUN_TESTS }}" >> $GITHUB_OUTPUT - echo "run_sonarqube=${{ env.RUN_SONARQUBE }}" >> $GITHUB_OUTPUT - echo "environment=${{ env.ENVIRONMENT }}" >> $GITHUB_OUTPUT - echo "cluster_name=${{ env.CLUSTER_NAME }}" >> $GITHUB_OUTPUT - echo "load_custom_vars=${{ env.LOAD_CUSTOM_VARS }}" >> $GITHUB_OUTPUT - echo "tag_strategy=${{ env.TAG_STRATEGY }}" >> $GITHUB_OUTPUT - echo "run_sca_scan=${{ env.RUN_SCA_SCAN }}" >> $GITHUB_OUTPUT - - # From vars.env (if loaded) or fallback to defaults - echo "client_dockerfile_path=${DOCKERFILE_CLIENT:-./Dockerfile}" >> $GITHUB_OUTPUT - echo "worker_dockerfile_path=${DOCKERFILE_WORKER:-./Dockerfile.worker}" >> $GITHUB_OUTPUT - - # From vars.env (if loaded) or fallback to defaults - echo "service_name=${SERVICE_NAME:-github.event.repository.name}" >> $GITHUB_OUTPUT - echo "service_type=${SERVICE_TYPE:-}" >> $GITHUB_OUTPUT - echo "sonarqube_host=${SONARQUBE_HOST:-}" >> $GITHUB_OUTPUT - echo "has_submodules=${HAS_SUBMODULES:-true}" >> $GITHUB_OUTPUT - echo "dotnet_version=${DOTNET_VERSION:-10.0.x}" >> $GITHUB_OUTPUT - # SCA Scan Configuration - echo "sca_project_server=${SCA_PROJECT_SERVER:-blocks-release-server}" >> $GITHUB_OUTPUT - echo "sca_project_client=${SCA_PROJECT_CLIENT:-blocks-release-client}" >> $GITHUB_OUTPUT - echo "project_version=${PROJECT_VERSION:-${{ github.ref_name }}}" >> $GITHUB_OUTPUT - echo "dependency_track_host=${DEPENDENCY_TRACK_HOST:-api-dt.seliseblocks.com}" >> $GITHUB_OUTPUT - echo "dependency_track_frontend_url=${DEPENDENCY_TRACK_FRONTEND_URL:-sca.seliseblocks.com}" >> $GITHUB_OUTPUT - - # SonarQube specific configurations (map from vars.env names) - echo "sonar_project_key_server=${SONAR_KEY_SERVER:-blocks-release-server}" >> $GITHUB_OUTPUT - echo "sonar_project_key_client=${SONAR_KEY_CLIENT:-blocks-release-client}" >> $GITHUB_OUTPUT - echo "sonar_organization=${AUTHOR:-}" >> $GITHUB_OUTPUT - echo "solution_name=${SOLUTION_NAME:-YourSolution.sln}" >> $GITHUB_OUTPUT - echo "working_directory=${WORKING_DIRECTORY:-./server}" >> $GITHUB_OUTPUT - - # Version based on environment (from versions.env) - ENVIRONMENT="${{ env.ENVIRONMENT }}" - case "$ENVIRONMENT" in - "dev") - VERSION="${DEV_VERSION:-}" - ;; - "stg") - VERSION="${STAGE_VERSION:-}" - ;; - "prod") - VERSION="${PROD_VERSION:-}" - ;; - "uat") - VERSION="${UAT_VERSION:-}" - ;; - *) - VERSION="" - ;; - esac - echo "version=${VERSION}" >> $GITHUB_OUTPUT - - echo "### 🔧 Pipeline Configuration" >> $GITHUB_STEP_SUMMARY - echo "- **Service**: ${SERVICE_NAME:-'Not set'}" >> $GITHUB_STEP_SUMMARY - echo "- **Version Suffix**: ${VERSION:-'None'}" >> $GITHUB_STEP_SUMMARY - echo "- **Type**: ${SERVICE_TYPE:-'Not set'}" >> $GITHUB_STEP_SUMMARY - echo "- **Environment**: ${{ env.ENVIRONMENT }}" >> $GITHUB_STEP_SUMMARY - echo "- **Image Tag Strategy**: ${{ env.TAG_STRATEGY }}" >> $GITHUB_STEP_SUMMARY - echo "- **Cluster**: ${{ env.CLUSTER_NAME }}" >> $GITHUB_STEP_SUMMARY - echo "- **Tests Enabled**: ${{ env.RUN_TESTS }}" >> $GITHUB_STEP_SUMMARY - echo "- **SonarQube Enabled**: ${{ env.RUN_SONARQUBE }}" >> $GITHUB_STEP_SUMMARY - echo "- **SCA Scan Enabled**: ${{ env.RUN_SCA_SCAN }}" >> $GITHUB_STEP_SUMMARY - - # # Run tests and checks on PRs only (optional) - # pr-checks: - # if: github.event_name == 'pull_request' && needs.initialization.outputs.run_tests == 'true' - # needs: [initialization] - # uses: SELISEdigitalplatforms/blocks-inventory/.github/workflows/test-dotnet.yml@main - # with: - # SERVICE_NAME: ${{ needs.initialization.outputs.service_name }} - # LOAD_CUSTOM_VARS: ${{ needs.initialization.outputs.load_custom_vars == 'true' }} - # secrets: - # SELISE_GITHUB_PAT: ${{ secrets.SELISE_GITHUB_PAT }} - - # Run SonarQube analysis on both push and pull_request - sonarqube_server: - # if: github.event_name == 'pull_request' && needs.initialization.outputs.run_sonarqube == 'true' - if: needs.initialization.outputs.run_sonarqube == 'true' - needs: [ initialization ] - uses: SELISEdigitalplatforms/blocks-inventory/.github/workflows/sonarqube-dotnet.yml@main - with: - SOLUTION_NAME: ${{ needs.initialization.outputs.solution_name }} - SONARQUBE_HOST: ${{ needs.initialization.outputs.sonarqube_host }} - SONAR_PROJECT_KEY: ${{ needs.initialization.outputs.sonar_project_key_server }} - SONAR_ORGANIZATION: ${{ needs.initialization.outputs.sonar_organization }} - SONAR_TARGET_BRANCH: "dev" - WORKING_DIRECTORY: ${{ needs.initialization.outputs.working_directory }} + set -euo pipefail + test -f .github/variables/vars.env + { cat .github/variables/vars.env; echo; } >> "$GITHUB_ENV" - # Optional: Specify tool versions (defaults shown) - DOTNET_VERSION: ${{ needs.initialization.outputs.dotnet_version }} - # DOTNET_COVERAGE_VERSION: "17.13.1" # Use 17.13.1 for .NET 6 or older - DOTNET_SONARSCANNER_VERSION: "5.15.0" # SonarScanner version - JAVA_VERSION: "17" # Java for SonarScanner - # Optional: Skip quality gate check (default: false) - HAS_SUBMODULES: ${{ needs.initialization.outputs.has_submodules == 'true' }} - SKIP_QUALITY_GATE: false - # Optional: Custom NuGet sources (defaults to NuGet.org + nuget.selise.biz) - # Only specify if you need additional sources (comma-separated) - # NUGET_SOURCES: "https://api.nuget.org/v3/index.json,https://nuget.selise.biz/nuget,https://custom-feed.com" - secrets: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN_GLOBAL }} - SELISE_GITHUB_PAT: ${{ secrets.SELISE_GITHUB_PAT }} + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 - # ============================================== - # SCA Scan for .NET - Dependency Analysis - # ============================================== - sca_server: - if: needs.initialization.outputs.run_sca_scan == 'true' - needs: [ initialization ] - uses: SELISEdigitalplatforms/blocks-inventory/.github/workflows/sca-scan-dotnet.yml@main - with: - # Project Configuration - PROJECT_NAME: ${{ needs.initialization.outputs.sca_project_server }} - PROJECT_VERSION: ${{ needs.initialization.outputs.project_version }} - - # .NET Configuration - DOTNET_VERSION: ${{ needs.initialization.outputs.dotnet_version }} - - # Solution Configuration - WORKING_DIRECTORY: ${{ needs.initialization.outputs.working_directory }} - SOLUTION_NAME: ${{ needs.initialization.outputs.solution_name }} - - # NuGet Configuration (uses defaults: public NuGet + nuget.selise.biz) - # NUGET_SOURCES: "https://api.nuget.org/v3/index.json https://nuget.selise.biz/nuget" - - # SBOM Tool Configuration - # SBOM_TOOL: "cdxgen" # Options: "cdxgen" (recommended), "cyclonedx-dotnet" - USE_DOCKER: false # Value 'true' is Recommended for .NET (make false to use native installation) - # DOCKER_DOTNET_VERSION: "dotnet9" # Match your .NET version: dotnet6, dotnet7, dotnet8, dotnet9 - INCLUDE_FORMULATION: false - - # Dependency-Track Configuration - DEPENDENCY_TRACK_HOST: ${{ needs.initialization.outputs.dependency_track_host }} - DEPENDENCY_TRACK_FRONTEND_URL: ${{ needs.initialization.outputs.dependency_track_frontend_url }} - AUTO_CREATE_PROJECT: true - ARTIFACT_RETENTION_DAYS: 2 - - # Optional Features - HAS_SUBMODULES: ${{ needs.initialization.outputs.has_submodules == 'true' }} - - secrets: - DEPENDENCY_TRACK_API_KEY: ${{ secrets.DEPENDENCY_TRACK_API_KEY }} - SELISE_GITHUB_PAT: ${{ secrets.SELISE_GITHUB_PAT }} - - sonarqube_client: - if: needs.initialization.outputs.run_sonarqube == 'true' - needs: [initialization] - uses: ./.github/workflows/sonarqube-client.yml - with: - WORKING_DIRECTORY: "client" - SONARQUBE_HOST: ${{ needs.initialization.outputs.sonarqube_host }} - SONAR_PROJECT_KEY: ${{ needs.initialization.outputs.sonar_project_key_client }} - SONAR_ORGANIZATION: ${{ needs.initialization.outputs.sonar_organization }} - SONAR_TARGET_BRANCH: "dev" - NODE_VERSION: "22" - JAVA_VERSION: "17" - HAS_SUBMODULES: ${{ needs.initialization.outputs.has_submodules == 'true' }} - SKIP_QUALITY_GATE: false - secrets: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN_GLOBAL }} - SELISE_GITHUB_PAT: ${{ secrets.SELISE_GITHUB_PAT }} - - sca_client: - if: needs.initialization.outputs.run_sca_scan == 'true' - needs: [initialization] - uses: ./.github/workflows/sca-client.yml - with: - WORKING_DIRECTORY: "client" - PROJECT_NAME: ${{ needs.initialization.outputs.sca_project_client }} - PROJECT_VERSION: ${{ needs.initialization.outputs.project_version }} - NODE_VERSION: "22" - SPEC_VERSION: "1.6" - INCLUDE_FORMULATION: false - DEPENDENCY_TRACK_HOST: ${{ needs.initialization.outputs.dependency_track_host }} - DEPENDENCY_TRACK_FRONTEND_URL: ${{ needs.initialization.outputs.dependency_track_frontend_url }} - AUTO_CREATE_PROJECT: true - ARTIFACT_RETENTION_DAYS: 2 - HAS_SUBMODULES: ${{ needs.initialization.outputs.has_submodules == 'true' }} - secrets: - DEPENDENCY_TRACK_API_KEY: ${{ secrets.DEPENDENCY_TRACK_API_KEY }} - SELISE_GITHUB_PAT: ${{ secrets.SELISE_GITHUB_PAT }} - - # Build and push image - build-client: - # needs: [initialization, sonarqube_server, sonarqube_client, sca_server, sca_client] - needs: [ initialization, sonarqube_server, sonarqube_client ] - # if: ${{ github.event_name == 'push' && (success() || needs.sonarqube_server.result == 'skipped' ) && (success() || needs.sca_server.result == 'skipped' ) }} - if: | - github.event_name == 'push' && - always() && - needs.initialization.result == 'success' && - (needs.sonarqube_server.result == 'success' || needs.sonarqube_server.result == 'skipped') && - (needs.sonarqube_client.result == 'success' || needs.sonarqube_client.result == 'skipped') - uses: SELISEdigitalplatforms/blocks-inventory/.github/workflows/build-push.yml@main - with: - SERVICE_NAME: ${{ needs.initialization.outputs.service_name }} - # SERVICE_TYPE: ${{ needs.initialization.outputs.service_type }} - SERVICE_TYPE: "webservice" # e.g., webclient, webservice, winservice - ENVIRONMENT: ${{ needs.initialization.outputs.environment }} - TAG_STRATEGY: ${{ needs.initialization.outputs.tag_strategy }} # Options: "both" (default), "semantic", "commit" - DOCKERFILE_PATH: ${{ needs.initialization.outputs.client_dockerfile_path }} - # VERSION: ${{ needs.initialization.outputs.version }} # Optional: Comment out to skip version suffix - BUILD_ARGS: | - BUILD_VERSION=${{ github.sha }} - ASPNETCORE_ENVIRONMENT=${{ needs.initialization.outputs.environment }} - secrets: - AZURE_CREDENTIALS: ${{ secrets.AZURE_AKS_BLOCKS_CREDENTIALS }} - AZURE_CONTAINER_REGISTRY: ${{ secrets.AZURE_BLOCKS_CONTAINER_REGISTRY }} - ACR_RESOURCE_GROUP: ${{ secrets.ClUSTER_AKS_BLOCKS_RESOURCE_GROUP }} - SELISE_GITHUB_PAT: ${{ secrets.SELISE_GITHUB_PAT }} + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - # Update GitOps repository - update-gitops-client: - needs: [ initialization, build-client ] - if: | - github.event_name == 'push' && - always() && - needs.initialization.result == 'success' && - needs.build-client.result == 'success' - uses: SELISEdigitalplatforms/blocks-inventory/.github/workflows/update-gitops-central.yml@main - with: - SERVICE_NAME: ${{ needs.initialization.outputs.service_name }} - SERVICE_TYPE: "webservice" # Match the service type from build job - ENVIRONMENT: ${{ needs.initialization.outputs.environment }} - # VERSION: ${{ needs.initialization.outputs.version }} # Pass version for proper file naming - IMAGE_TAG: ${{ needs.build-client.outputs.image_tag }} - COMMIT_TAG: ${{ needs.build-client.outputs.commit_tag }} - SEMANTIC_TAG: ${{ needs.build-client.outputs.semantic_tag }} - TAG_STRATEGY: ${{ needs.initialization.outputs.tag_strategy }} # Options: "commit" (default), "semantic", or "primary" - CLUSTER_NAME: ${{ needs.initialization.outputs.cluster_name }} - # GITOPS_BRANCH: "main" - secrets: - SELISE_GITHUB_PAT: ${{ secrets.SELISE_GITHUB_PAT }} - AZURE_CONTAINER_REGISTRY: ${{ secrets.AZURE_BLOCKS_CONTAINER_REGISTRY }} + - name: Build & push ${{ matrix.component }} image + uses: docker/build-push-action@v6 + with: + context: . + file: ${{ env[matrix.dockerfile] }} + platforms: linux/amd64 + push: true + tags: ${{ secrets.DOCKERHUB_CR }}/${{ env.ENVIRONMENT }}-${{ env.REPO_NAME }}-${{ matrix.component }}:${{ github.sha }} + cache-from: type=gha,scope=${{ env.ENVIRONMENT }}-${{ env.REPO_NAME }}-${{ matrix.component }} + cache-to: type=gha,mode=max,scope=${{ env.ENVIRONMENT }}-${{ env.REPO_NAME }}-${{ matrix.component }} + + - name: Summary + run: | + echo "Pushed \`${{ secrets.DOCKERHUB_CR }}/${{ env.ENVIRONMENT }}-${{ env.REPO_NAME }}-${{ matrix.component }}:${{ github.sha }}\`" >> "$GITHUB_STEP_SUMMARY" + + deploy: + needs: build-and-push + runs-on: [self-hosted, cloud-devstg-runner] + timeout-minutes: 20 + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v4 - # Build and push image - build-worker: - # needs: [initialization, sonarqube_server, sonarqube_client, sca_server, sca_client] - needs: [ initialization, sonarqube_server, sonarqube_client ] - # if: ${{ github.event_name == 'push' && (success() || needs.sonarqube_server.result == 'skipped' ) && (success() || needs.sca_server.result == 'skipped' ) }} - if: | - github.event_name == 'push' && - always() && - needs.initialization.result == 'success' && - (needs.sonarqube_server.result == 'success' || needs.sonarqube_server.result == 'skipped') && - (needs.sonarqube_client.result == 'success' || needs.sonarqube_client.result == 'skipped') - uses: SELISEdigitalplatforms/blocks-inventory/.github/workflows/build-push.yml@main - with: - SERVICE_NAME: ${{ needs.initialization.outputs.service_name }} - # SERVICE_TYPE: ${{ needs.initialization.outputs.service_type }} - SERVICE_TYPE: "worker" # e.g., webclient, webservice, winservice - ENVIRONMENT: ${{ needs.initialization.outputs.environment }} - TAG_STRATEGY: ${{ needs.initialization.outputs.tag_strategy }} # Options: "both" (default), "semantic", "commit" - DOCKERFILE_PATH: ${{ needs.initialization.outputs.worker_dockerfile_path }} - # VERSION: ${{ needs.initialization.outputs.version }} # Optional: Comment out to skip version suffix - BUILD_ARGS: | - BUILD_VERSION=${{ github.sha }} - secrets: - AZURE_CREDENTIALS: ${{ secrets.AZURE_AKS_BLOCKS_CREDENTIALS }} - AZURE_CONTAINER_REGISTRY: ${{ secrets.AZURE_BLOCKS_CONTAINER_REGISTRY }} - ACR_RESOURCE_GROUP: ${{ secrets.ClUSTER_AKS_BLOCKS_RESOURCE_GROUP }} - SELISE_GITHUB_PAT: ${{ secrets.SELISE_GITHUB_PAT }} + - name: Load repo variables (vars.env) + run: | + set -euo pipefail + test -f .github/variables/vars.env + { cat .github/variables/vars.env; echo; } >> "$GITHUB_ENV" + + # The images are private, so the runner needs registry auth. Logging in + # here keeps the job self-contained instead of relying on whatever + # credentials happen to sit in the runner user's ~/.docker/config.json. + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - # Update GitOps repository - update-gitops-worker: - needs: [ initialization, build-worker ] - if: | - github.event_name == 'push' && - always() && - needs.initialization.result == 'success' && - needs.build-worker.result == 'success' - uses: SELISEdigitalplatforms/blocks-inventory/.github/workflows/update-gitops-central.yml@main - with: - SERVICE_NAME: ${{ needs.initialization.outputs.service_name }} - SERVICE_TYPE: "worker" # Match the service type from build job - ENVIRONMENT: ${{ needs.initialization.outputs.environment }} - # VERSION: ${{ needs.initialization.outputs.version }} # Pass version for proper file naming - IMAGE_TAG: ${{ needs.build-worker.outputs.image_tag }} - COMMIT_TAG: ${{ needs.build-worker.outputs.commit_tag }} - SEMANTIC_TAG: ${{ needs.build-worker.outputs.semantic_tag }} - TAG_STRATEGY: ${{ needs.initialization.outputs.tag_strategy }} # Options: "commit" (default), "semantic", or "primary" - CLUSTER_NAME: ${{ needs.initialization.outputs.cluster_name }} - # GITOPS_BRANCH: "main" - secrets: - SELISE_GITHUB_PAT: ${{ secrets.SELISE_GITHUB_PAT }} - AZURE_CONTAINER_REGISTRY: ${{ secrets.AZURE_BLOCKS_CONTAINER_REGISTRY }} + - name: Update service containers on the VM + env: + INFRA_DIR: ${{ vars.BLOCKS_INFRA_DIR }} + IMAGE_TAG: ${{ github.sha }} + run: | + set -euo pipefail + INFRA_DIR="${INFRA_DIR:-/root/blocks-infra}" + SVC="${REPO_NAME#blocks-}" + TAG_VAR="$(echo "${SVC}_TAG" | tr '[:lower:]' '[:upper:]' | tr '-' '_')" + + # Fail fast with a clear message rather than a confusing compose error. + test -d "$INFRA_DIR" || { echo "::error::INFRA_DIR not found: $INFRA_DIR"; exit 1; } + test -f "$INFRA_DIR/.env" || { echo "::error::missing $INFRA_DIR/.env"; exit 1; } + test -f "$INFRA_DIR/docker-compose.yml" || { echo "::error::missing $INFRA_DIR/docker-compose.yml"; exit 1; } + + cd "$INFRA_DIR" + export "$TAG_VAR=$IMAGE_TAG" + echo "Deploying $SVC ($TAG_VAR=$IMAGE_TAG) from $INFRA_DIR" + + # --profile infra is required for a valid compose project: the app + # services declare depends_on mongodb, which lives in that profile. + # It does NOT start infra here because --no-deps is set and only the + # two service containers are named. mongodb-seed is in its own opt-in + # [seed] profile, so this can never re-seed and wipe the database. + docker compose --env-file .env --profile infra --profile "$SVC" \ + pull "${SVC}-api" "${SVC}-worker" + docker compose --env-file .env --profile infra --profile "$SVC" \ + up -d --no-deps --force-recreate "${SVC}-api" "${SVC}-worker" + + docker image prune -f >/dev/null 2>&1 || true + echo "Deployed \`$SVC\` -> \`$IMAGE_TAG\` on $(hostname)" >> "$GITHUB_STEP_SUMMARY" + + # Without this the job goes green even when a container crash-loops on + # startup, which is the most likely way a bad deploy shows up. + - name: Verify containers are healthy + env: + IMAGE_TAG: ${{ github.sha }} + run: | + set -euo pipefail + SVC="${REPO_NAME#blocks-}" + sleep 15 + rc=0 + for c in "${SVC}-api" "${SVC}-worker"; do + state=$(docker inspect -f '{{.State.Status}}' "$c" 2>/dev/null || echo missing) + restarts=$(docker inspect -f '{{.RestartCount}}' "$c" 2>/dev/null || echo '?') + running_tag=$(docker inspect -f '{{.Config.Image}}' "$c" 2>/dev/null || echo '?') + echo "$c: state=$state restarts=$restarts image=$running_tag" + if [ "$state" != "running" ]; then + echo "::error::$c is '$state', expected 'running'" + docker logs --tail 40 "$c" 2>&1 || true + rc=1 + fi + case "$running_tag" in + *":$IMAGE_TAG") ;; + *) echo "::error::$c is not running the expected tag $IMAGE_TAG"; rc=1 ;; + esac + done + echo "| container | state | restarts |" >> "$GITHUB_STEP_SUMMARY" + echo "|---|---|---|" >> "$GITHUB_STEP_SUMMARY" + for c in "${SVC}-api" "${SVC}-worker"; do + echo "| \`$c\` | $(docker inspect -f '{{.State.Status}}' "$c" 2>/dev/null || echo missing) | $(docker inspect -f '{{.RestartCount}}' "$c" 2>/dev/null || echo '?') |" >> "$GITHUB_STEP_SUMMARY" + done + exit $rc From a0906f359a482c9fdbbf93794dd7d21cbb9b6ef5 Mon Sep 17 00:00:00 2001 From: sojeebakhtar Date: Wed, 9 Sep 2026 16:22:49 +0600 Subject: [PATCH 2/6] ci(dev): pin deploy runner labels to self-hosted, Linux, X64, cloud-devstg-runner Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci-dev.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-dev.yml b/.github/workflows/ci-dev.yml index 614eaf3d..dd73f88c 100644 --- a/.github/workflows/ci-dev.yml +++ b/.github/workflows/ci-dev.yml @@ -66,7 +66,7 @@ jobs: deploy: needs: build-and-push - runs-on: [self-hosted, cloud-devstg-runner] + runs-on: [self-hosted, Linux, X64, cloud-devstg-runner] timeout-minutes: 20 permissions: contents: read From 5a49e869c5ad4e15d8ab9b0bcc437fe18bb79e30 Mon Sep 17 00:00:00 2001 From: sojeebakhtar Date: Wed, 9 Sep 2026 16:47:21 +0600 Subject: [PATCH 3/6] ci(dev): persist the deployed tag in .env The deploy step only exported _TAG into its own shell, so .env kept pointing at the previous image. Any later `docker compose up -d` or `./run.sh -all` would then silently roll the service back to the old tag (observed on localization: container on 440d3576, .env on 32a54c64). Write the tag into .env before bringing the containers up, and fail the job if compose does not resolve to the tag being deployed. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci-dev.yml | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci-dev.yml b/.github/workflows/ci-dev.yml index dd73f88c..679b0159 100644 --- a/.github/workflows/ci-dev.yml +++ b/.github/workflows/ci-dev.yml @@ -66,7 +66,7 @@ jobs: deploy: needs: build-and-push - runs-on: [self-hosted, Linux, X64, cloud-devstg-runner] + runs-on: [self-hosted, cloud-devstg-runner] timeout-minutes: 20 permissions: contents: read @@ -105,9 +105,28 @@ jobs: test -f "$INFRA_DIR/docker-compose.yml" || { echo "::error::missing $INFRA_DIR/docker-compose.yml"; exit 1; } cd "$INFRA_DIR" - export "$TAG_VAR=$IMAGE_TAG" + + # Persist the tag in .env instead of only exporting it for this shell. + # Otherwise .env keeps pointing at the previous tag, and any later + # `docker compose up -d` or `./run.sh -all` silently rolls this + # service back to the old image. + cp .env ".env.bak-deploy" + if grep -qE "^${TAG_VAR}=" .env; then + sed -i -E "s|^${TAG_VAR}=.*|${TAG_VAR}=${IMAGE_TAG}|" .env + else + printf '%s=%s\n' "$TAG_VAR" "$IMAGE_TAG" >> .env + fi echo "Deploying $SVC ($TAG_VAR=$IMAGE_TAG) from $INFRA_DIR" + # Confirm .env now resolves to the tag we are about to deploy. + resolved=$(docker compose --env-file .env --profile infra --profile "$SVC" config 2>/dev/null \ + | grep -oE "image: [^ ]*${SVC}-api:[^ ]*" | head -1) + echo "compose resolves: $resolved" + case "$resolved" in + *":$IMAGE_TAG") ;; + *) echo "::error::.env did not take the new tag ($TAG_VAR=$IMAGE_TAG); got $resolved"; exit 1 ;; + esac + # --profile infra is required for a valid compose project: the app # services declare depends_on mongodb, which lives in that profile. # It does NOT start infra here because --no-deps is set and only the From 78f19371bbf58a84600b1fff205afb231aca6ae2 Mon Sep 17 00:00:00 2001 From: sojeebakhtar Date: Thu, 10 Sep 2026 00:21:14 +0600 Subject: [PATCH 4/6] ci(dev): restore the Azure/GitOps dev pipeline Dev traffic is served from the original Azure infra again, so the dev pipeline should build and deploy there rather than to the blocks-infra VM. Restores .github/workflows/ci-dev.yml as it was on backup/dev-2026-09-09. The VM-targeting version is preserved on backup/vm-target-2026-09-10 for when the remaining service dependencies are resolved. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci-dev.yml | 513 ++++++++++++++++++++++++----------- 1 file changed, 357 insertions(+), 156 deletions(-) diff --git a/.github/workflows/ci-dev.yml b/.github/workflows/ci-dev.yml index 679b0159..eb351405 100644 --- a/.github/workflows/ci-dev.yml +++ b/.github/workflows/ci-dev.yml @@ -1,173 +1,374 @@ -name: CI - Dev +name: CI/CD - Dev Environment on: push: - branches: [dev] - -concurrency: - group: ci-dev-${{ github.ref }} - cancel-in-progress: true + branches: + - dev + pull_request: + branches: + - dev + types: + - opened + - reopened + - synchronize env: - ENVIRONMENT: dev + # ======================================== + # Environment-Specific Configuration + # (Different for each environment) + # ======================================== + ENVIRONMENT: "dev" # dev, stg, uat, prod + CLUSTER_NAME: "aks-blocks-dev" # TODO: Set your dev cluster name + + # ======================================== + # Quality Checks + # ======================================== + RUN_TESTS: "true" # Usually disabled in dev for speed + RUN_SONARQUBE: "true" # Enable for testing the new dotnet-coverage integration + RUN_SCA_SCAN: "true" # Enable SCA Scan for dependency analysis + + # ======================================== + # Tag Stategy for Build and GitOps + # ======================================== + TAG_STRATEGY: "commit" # Options: "both" (default), "semantic", "commit" + # ======================================== + # Shared Configuration Loading + # ======================================== + LOAD_CUSTOM_VARS: "true" # Load shared config from .github/variables/vars.env + # Set to 'false' only if you don't have vars.env + +concurrency: + group: ${{ github.ref_name }} + cancel-in-progress: true jobs: - build-and-push: + # Initialize configuration + initialization: runs-on: ubuntu-latest - timeout-minutes: 45 - permissions: - contents: read - strategy: - fail-fast: false - matrix: - include: - - component: api - dockerfile: DOCKERFILE_CLIENT - - component: worker - dockerfile: DOCKERFILE_WORKER - name: build-and-push (${{ matrix.component }}) + outputs: + cluster_name: ${{ steps.config.outputs.cluster_name }} + environment: ${{ steps.config.outputs.environment }} + load_custom_vars: ${{ steps.config.outputs.load_custom_vars }} + sca_project_server: ${{ steps.config.outputs.sca_project_server }} + sca_project_client: ${{ steps.config.outputs.sca_project_client }} + project_version: ${{ steps.config.outputs.project_version }} + run_tests: ${{ steps.config.outputs.run_tests }} + run_sonarqube: ${{ steps.config.outputs.run_sonarqube }} + run_sca_scan: ${{ steps.config.outputs.run_sca_scan }} + service_name: ${{ steps.config.outputs.service_name }} # e.g., your service name + service_type: ${{ steps.config.outputs.service_type }} # e.g., webclient, webservice, winservice + sonarqube_host: ${{ steps.config.outputs.sonarqube_host }} # e.g., https://code.selise.biz + sonar_project_key_server: ${{ steps.config.outputs.sonar_project_key_server }} + sonar_project_key_client: ${{ steps.config.outputs.sonar_project_key_client }} + sonar_organization: ${{ steps.config.outputs.sonar_organization }} # e.g., your SonarQube org + solution_name: ${{ steps.config.outputs.solution_name }} # e.g., YourSolution.sln for .NET + tag_strategy: ${{ steps.config.outputs.tag_strategy }} # e.g., commit, semantic + working_directory: ${{ steps.config.outputs.working_directory }} # e.g., ./src. Used for SonarQube + version: ${{ steps.config.outputs.version }} # Version suffix based on environment + client_dockerfile_path: ${{ steps.config.outputs.client_dockerfile_path }} # Path to Dockerfile + worker_dockerfile_path: ${{ steps.config.outputs.worker_dockerfile_path }} # Path to Window/Console service Dockerfile + dependency_track_host: ${{ steps.config.outputs.dependency_track_host }} # e.g., api-dt.seliseblocks.com + dependency_track_frontend_url: ${{ steps.config.outputs.dependency_track_frontend_url }} # e.g., sca.seliseblocks.com + dotnet_version: ${{ steps.config.outputs.dotnet_version }} + has_submodules: ${{ steps.config.outputs.has_submodules }} steps: - - name: Checkout - uses: actions/checkout@v4 - - # vars.env supplies REPO_NAME, DOCKERFILE_CLIENT, DOCKERFILE_WORKER. - # The trailing `echo` is required: the vars.env files do not end with a - # newline, so a bare `cat >>` would splice the last variable onto - # whatever GitHub appends to $GITHUB_ENV next. - - name: Load repo variables (vars.env) - run: | - set -euo pipefail - test -f .github/variables/vars.env - { cat .github/variables/vars.env; echo; } >> "$GITHUB_ENV" + - uses: actions/checkout@v4 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to Docker Hub - uses: docker/login-action@v3 + # Load shared variables first (if enabled) + - name: Load Shared Configuration + if: env.LOAD_CUSTOM_VARS == 'true' + uses: SELISEdigitalplatforms/blocks-inventory/.github/actions/setvars@main with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} + varFilePath: ./.github/variables/ - - name: Build & push ${{ matrix.component }} image - uses: docker/build-push-action@v6 - with: - context: . - file: ${{ env[matrix.dockerfile] }} - platforms: linux/amd64 - push: true - tags: ${{ secrets.DOCKERHUB_CR }}/${{ env.ENVIRONMENT }}-${{ env.REPO_NAME }}-${{ matrix.component }}:${{ github.sha }} - cache-from: type=gha,scope=${{ env.ENVIRONMENT }}-${{ env.REPO_NAME }}-${{ matrix.component }} - cache-to: type=gha,mode=max,scope=${{ env.ENVIRONMENT }}-${{ env.REPO_NAME }}-${{ matrix.component }} - - - name: Summary + - name: Set Configuration Outputs + id: config run: | - echo "Pushed \`${{ secrets.DOCKERHUB_CR }}/${{ env.ENVIRONMENT }}-${{ env.REPO_NAME }}-${{ matrix.component }}:${{ github.sha }}\`" >> "$GITHUB_STEP_SUMMARY" - - deploy: - needs: build-and-push - runs-on: [self-hosted, cloud-devstg-runner] - timeout-minutes: 20 - permissions: - contents: read - steps: - - name: Checkout - uses: actions/checkout@v4 + # From workflow env (environment-specific) + echo "run_tests=${{ env.RUN_TESTS }}" >> $GITHUB_OUTPUT + echo "run_sonarqube=${{ env.RUN_SONARQUBE }}" >> $GITHUB_OUTPUT + echo "environment=${{ env.ENVIRONMENT }}" >> $GITHUB_OUTPUT + echo "cluster_name=${{ env.CLUSTER_NAME }}" >> $GITHUB_OUTPUT + echo "load_custom_vars=${{ env.LOAD_CUSTOM_VARS }}" >> $GITHUB_OUTPUT + echo "tag_strategy=${{ env.TAG_STRATEGY }}" >> $GITHUB_OUTPUT + echo "run_sca_scan=${{ env.RUN_SCA_SCAN }}" >> $GITHUB_OUTPUT - - name: Load repo variables (vars.env) - run: | - set -euo pipefail - test -f .github/variables/vars.env - { cat .github/variables/vars.env; echo; } >> "$GITHUB_ENV" - - # The images are private, so the runner needs registry auth. Logging in - # here keeps the job self-contained instead of relying on whatever - # credentials happen to sit in the runner user's ~/.docker/config.json. - - name: Log in to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} + # From vars.env (if loaded) or fallback to defaults + echo "client_dockerfile_path=${DOCKERFILE_CLIENT:-./Dockerfile}" >> $GITHUB_OUTPUT + echo "worker_dockerfile_path=${DOCKERFILE_WORKER:-./Dockerfile.worker}" >> $GITHUB_OUTPUT - - name: Update service containers on the VM - env: - INFRA_DIR: ${{ vars.BLOCKS_INFRA_DIR }} - IMAGE_TAG: ${{ github.sha }} - run: | - set -euo pipefail - INFRA_DIR="${INFRA_DIR:-/root/blocks-infra}" - SVC="${REPO_NAME#blocks-}" - TAG_VAR="$(echo "${SVC}_TAG" | tr '[:lower:]' '[:upper:]' | tr '-' '_')" - - # Fail fast with a clear message rather than a confusing compose error. - test -d "$INFRA_DIR" || { echo "::error::INFRA_DIR not found: $INFRA_DIR"; exit 1; } - test -f "$INFRA_DIR/.env" || { echo "::error::missing $INFRA_DIR/.env"; exit 1; } - test -f "$INFRA_DIR/docker-compose.yml" || { echo "::error::missing $INFRA_DIR/docker-compose.yml"; exit 1; } - - cd "$INFRA_DIR" - - # Persist the tag in .env instead of only exporting it for this shell. - # Otherwise .env keeps pointing at the previous tag, and any later - # `docker compose up -d` or `./run.sh -all` silently rolls this - # service back to the old image. - cp .env ".env.bak-deploy" - if grep -qE "^${TAG_VAR}=" .env; then - sed -i -E "s|^${TAG_VAR}=.*|${TAG_VAR}=${IMAGE_TAG}|" .env - else - printf '%s=%s\n' "$TAG_VAR" "$IMAGE_TAG" >> .env - fi - echo "Deploying $SVC ($TAG_VAR=$IMAGE_TAG) from $INFRA_DIR" - - # Confirm .env now resolves to the tag we are about to deploy. - resolved=$(docker compose --env-file .env --profile infra --profile "$SVC" config 2>/dev/null \ - | grep -oE "image: [^ ]*${SVC}-api:[^ ]*" | head -1) - echo "compose resolves: $resolved" - case "$resolved" in - *":$IMAGE_TAG") ;; - *) echo "::error::.env did not take the new tag ($TAG_VAR=$IMAGE_TAG); got $resolved"; exit 1 ;; + # From vars.env (if loaded) or fallback to defaults + echo "service_name=${SERVICE_NAME:-github.event.repository.name}" >> $GITHUB_OUTPUT + echo "service_type=${SERVICE_TYPE:-}" >> $GITHUB_OUTPUT + echo "sonarqube_host=${SONARQUBE_HOST:-}" >> $GITHUB_OUTPUT + echo "has_submodules=${HAS_SUBMODULES:-true}" >> $GITHUB_OUTPUT + echo "dotnet_version=${DOTNET_VERSION:-10.0.x}" >> $GITHUB_OUTPUT + # SCA Scan Configuration + echo "sca_project_server=${SCA_PROJECT_SERVER:-blocks-release-server}" >> $GITHUB_OUTPUT + echo "sca_project_client=${SCA_PROJECT_CLIENT:-blocks-release-client}" >> $GITHUB_OUTPUT + echo "project_version=${PROJECT_VERSION:-${{ github.ref_name }}}" >> $GITHUB_OUTPUT + echo "dependency_track_host=${DEPENDENCY_TRACK_HOST:-api-dt.seliseblocks.com}" >> $GITHUB_OUTPUT + echo "dependency_track_frontend_url=${DEPENDENCY_TRACK_FRONTEND_URL:-sca.seliseblocks.com}" >> $GITHUB_OUTPUT + + # SonarQube specific configurations (map from vars.env names) + echo "sonar_project_key_server=${SONAR_KEY_SERVER:-blocks-release-server}" >> $GITHUB_OUTPUT + echo "sonar_project_key_client=${SONAR_KEY_CLIENT:-blocks-release-client}" >> $GITHUB_OUTPUT + echo "sonar_organization=${AUTHOR:-}" >> $GITHUB_OUTPUT + echo "solution_name=${SOLUTION_NAME:-YourSolution.sln}" >> $GITHUB_OUTPUT + echo "working_directory=${WORKING_DIRECTORY:-./server}" >> $GITHUB_OUTPUT + + # Version based on environment (from versions.env) + ENVIRONMENT="${{ env.ENVIRONMENT }}" + case "$ENVIRONMENT" in + "dev") + VERSION="${DEV_VERSION:-}" + ;; + "stg") + VERSION="${STAGE_VERSION:-}" + ;; + "prod") + VERSION="${PROD_VERSION:-}" + ;; + "uat") + VERSION="${UAT_VERSION:-}" + ;; + *) + VERSION="" + ;; esac + echo "version=${VERSION}" >> $GITHUB_OUTPUT - # --profile infra is required for a valid compose project: the app - # services declare depends_on mongodb, which lives in that profile. - # It does NOT start infra here because --no-deps is set and only the - # two service containers are named. mongodb-seed is in its own opt-in - # [seed] profile, so this can never re-seed and wipe the database. - docker compose --env-file .env --profile infra --profile "$SVC" \ - pull "${SVC}-api" "${SVC}-worker" - docker compose --env-file .env --profile infra --profile "$SVC" \ - up -d --no-deps --force-recreate "${SVC}-api" "${SVC}-worker" - - docker image prune -f >/dev/null 2>&1 || true - echo "Deployed \`$SVC\` -> \`$IMAGE_TAG\` on $(hostname)" >> "$GITHUB_STEP_SUMMARY" - - # Without this the job goes green even when a container crash-loops on - # startup, which is the most likely way a bad deploy shows up. - - name: Verify containers are healthy - env: - IMAGE_TAG: ${{ github.sha }} - run: | - set -euo pipefail - SVC="${REPO_NAME#blocks-}" - sleep 15 - rc=0 - for c in "${SVC}-api" "${SVC}-worker"; do - state=$(docker inspect -f '{{.State.Status}}' "$c" 2>/dev/null || echo missing) - restarts=$(docker inspect -f '{{.RestartCount}}' "$c" 2>/dev/null || echo '?') - running_tag=$(docker inspect -f '{{.Config.Image}}' "$c" 2>/dev/null || echo '?') - echo "$c: state=$state restarts=$restarts image=$running_tag" - if [ "$state" != "running" ]; then - echo "::error::$c is '$state', expected 'running'" - docker logs --tail 40 "$c" 2>&1 || true - rc=1 - fi - case "$running_tag" in - *":$IMAGE_TAG") ;; - *) echo "::error::$c is not running the expected tag $IMAGE_TAG"; rc=1 ;; - esac - done - echo "| container | state | restarts |" >> "$GITHUB_STEP_SUMMARY" - echo "|---|---|---|" >> "$GITHUB_STEP_SUMMARY" - for c in "${SVC}-api" "${SVC}-worker"; do - echo "| \`$c\` | $(docker inspect -f '{{.State.Status}}' "$c" 2>/dev/null || echo missing) | $(docker inspect -f '{{.RestartCount}}' "$c" 2>/dev/null || echo '?') |" >> "$GITHUB_STEP_SUMMARY" - done - exit $rc + echo "### 🔧 Pipeline Configuration" >> $GITHUB_STEP_SUMMARY + echo "- **Service**: ${SERVICE_NAME:-'Not set'}" >> $GITHUB_STEP_SUMMARY + echo "- **Version Suffix**: ${VERSION:-'None'}" >> $GITHUB_STEP_SUMMARY + echo "- **Type**: ${SERVICE_TYPE:-'Not set'}" >> $GITHUB_STEP_SUMMARY + echo "- **Environment**: ${{ env.ENVIRONMENT }}" >> $GITHUB_STEP_SUMMARY + echo "- **Image Tag Strategy**: ${{ env.TAG_STRATEGY }}" >> $GITHUB_STEP_SUMMARY + echo "- **Cluster**: ${{ env.CLUSTER_NAME }}" >> $GITHUB_STEP_SUMMARY + echo "- **Tests Enabled**: ${{ env.RUN_TESTS }}" >> $GITHUB_STEP_SUMMARY + echo "- **SonarQube Enabled**: ${{ env.RUN_SONARQUBE }}" >> $GITHUB_STEP_SUMMARY + echo "- **SCA Scan Enabled**: ${{ env.RUN_SCA_SCAN }}" >> $GITHUB_STEP_SUMMARY + + # # Run tests and checks on PRs only (optional) + # pr-checks: + # if: github.event_name == 'pull_request' && needs.initialization.outputs.run_tests == 'true' + # needs: [initialization] + # uses: SELISEdigitalplatforms/blocks-inventory/.github/workflows/test-dotnet.yml@main + # with: + # SERVICE_NAME: ${{ needs.initialization.outputs.service_name }} + # LOAD_CUSTOM_VARS: ${{ needs.initialization.outputs.load_custom_vars == 'true' }} + # secrets: + # SELISE_GITHUB_PAT: ${{ secrets.SELISE_GITHUB_PAT }} + + # Run SonarQube analysis on both push and pull_request + sonarqube_server: + # if: github.event_name == 'pull_request' && needs.initialization.outputs.run_sonarqube == 'true' + if: needs.initialization.outputs.run_sonarqube == 'true' + needs: [ initialization ] + uses: SELISEdigitalplatforms/blocks-inventory/.github/workflows/sonarqube-dotnet.yml@main + with: + SOLUTION_NAME: ${{ needs.initialization.outputs.solution_name }} + SONARQUBE_HOST: ${{ needs.initialization.outputs.sonarqube_host }} + SONAR_PROJECT_KEY: ${{ needs.initialization.outputs.sonar_project_key_server }} + SONAR_ORGANIZATION: ${{ needs.initialization.outputs.sonar_organization }} + SONAR_TARGET_BRANCH: "dev" + WORKING_DIRECTORY: ${{ needs.initialization.outputs.working_directory }} + + # Optional: Specify tool versions (defaults shown) + DOTNET_VERSION: ${{ needs.initialization.outputs.dotnet_version }} + # DOTNET_COVERAGE_VERSION: "17.13.1" # Use 17.13.1 for .NET 6 or older + DOTNET_SONARSCANNER_VERSION: "5.15.0" # SonarScanner version + JAVA_VERSION: "17" # Java for SonarScanner + # Optional: Skip quality gate check (default: false) + HAS_SUBMODULES: ${{ needs.initialization.outputs.has_submodules == 'true' }} + SKIP_QUALITY_GATE: false + # Optional: Custom NuGet sources (defaults to NuGet.org + nuget.selise.biz) + # Only specify if you need additional sources (comma-separated) + # NUGET_SOURCES: "https://api.nuget.org/v3/index.json,https://nuget.selise.biz/nuget,https://custom-feed.com" + secrets: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN_GLOBAL }} + SELISE_GITHUB_PAT: ${{ secrets.SELISE_GITHUB_PAT }} + + # ============================================== + # SCA Scan for .NET - Dependency Analysis + # ============================================== + sca_server: + if: needs.initialization.outputs.run_sca_scan == 'true' + needs: [ initialization ] + uses: SELISEdigitalplatforms/blocks-inventory/.github/workflows/sca-scan-dotnet.yml@main + with: + # Project Configuration + PROJECT_NAME: ${{ needs.initialization.outputs.sca_project_server }} + PROJECT_VERSION: ${{ needs.initialization.outputs.project_version }} + + # .NET Configuration + DOTNET_VERSION: ${{ needs.initialization.outputs.dotnet_version }} + + # Solution Configuration + WORKING_DIRECTORY: ${{ needs.initialization.outputs.working_directory }} + SOLUTION_NAME: ${{ needs.initialization.outputs.solution_name }} + + # NuGet Configuration (uses defaults: public NuGet + nuget.selise.biz) + # NUGET_SOURCES: "https://api.nuget.org/v3/index.json https://nuget.selise.biz/nuget" + + # SBOM Tool Configuration + # SBOM_TOOL: "cdxgen" # Options: "cdxgen" (recommended), "cyclonedx-dotnet" + USE_DOCKER: false # Value 'true' is Recommended for .NET (make false to use native installation) + # DOCKER_DOTNET_VERSION: "dotnet9" # Match your .NET version: dotnet6, dotnet7, dotnet8, dotnet9 + INCLUDE_FORMULATION: false + + # Dependency-Track Configuration + DEPENDENCY_TRACK_HOST: ${{ needs.initialization.outputs.dependency_track_host }} + DEPENDENCY_TRACK_FRONTEND_URL: ${{ needs.initialization.outputs.dependency_track_frontend_url }} + AUTO_CREATE_PROJECT: true + ARTIFACT_RETENTION_DAYS: 2 + + # Optional Features + HAS_SUBMODULES: ${{ needs.initialization.outputs.has_submodules == 'true' }} + + secrets: + DEPENDENCY_TRACK_API_KEY: ${{ secrets.DEPENDENCY_TRACK_API_KEY }} + SELISE_GITHUB_PAT: ${{ secrets.SELISE_GITHUB_PAT }} + + sonarqube_client: + if: needs.initialization.outputs.run_sonarqube == 'true' + needs: [initialization] + uses: ./.github/workflows/sonarqube-client.yml + with: + WORKING_DIRECTORY: "client" + SONARQUBE_HOST: ${{ needs.initialization.outputs.sonarqube_host }} + SONAR_PROJECT_KEY: ${{ needs.initialization.outputs.sonar_project_key_client }} + SONAR_ORGANIZATION: ${{ needs.initialization.outputs.sonar_organization }} + SONAR_TARGET_BRANCH: "dev" + NODE_VERSION: "22" + JAVA_VERSION: "17" + HAS_SUBMODULES: ${{ needs.initialization.outputs.has_submodules == 'true' }} + SKIP_QUALITY_GATE: false + secrets: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN_GLOBAL }} + SELISE_GITHUB_PAT: ${{ secrets.SELISE_GITHUB_PAT }} + + sca_client: + if: needs.initialization.outputs.run_sca_scan == 'true' + needs: [initialization] + uses: ./.github/workflows/sca-client.yml + with: + WORKING_DIRECTORY: "client" + PROJECT_NAME: ${{ needs.initialization.outputs.sca_project_client }} + PROJECT_VERSION: ${{ needs.initialization.outputs.project_version }} + NODE_VERSION: "22" + SPEC_VERSION: "1.6" + INCLUDE_FORMULATION: false + DEPENDENCY_TRACK_HOST: ${{ needs.initialization.outputs.dependency_track_host }} + DEPENDENCY_TRACK_FRONTEND_URL: ${{ needs.initialization.outputs.dependency_track_frontend_url }} + AUTO_CREATE_PROJECT: true + ARTIFACT_RETENTION_DAYS: 2 + HAS_SUBMODULES: ${{ needs.initialization.outputs.has_submodules == 'true' }} + secrets: + DEPENDENCY_TRACK_API_KEY: ${{ secrets.DEPENDENCY_TRACK_API_KEY }} + SELISE_GITHUB_PAT: ${{ secrets.SELISE_GITHUB_PAT }} + + # Build and push image + build-client: + # needs: [initialization, sonarqube_server, sonarqube_client, sca_server, sca_client] + needs: [ initialization, sonarqube_server, sonarqube_client ] + # if: ${{ github.event_name == 'push' && (success() || needs.sonarqube_server.result == 'skipped' ) && (success() || needs.sca_server.result == 'skipped' ) }} + if: | + github.event_name == 'push' && + always() && + needs.initialization.result == 'success' && + (needs.sonarqube_server.result == 'success' || needs.sonarqube_server.result == 'skipped') && + (needs.sonarqube_client.result == 'success' || needs.sonarqube_client.result == 'skipped') + uses: SELISEdigitalplatforms/blocks-inventory/.github/workflows/build-push.yml@main + with: + SERVICE_NAME: ${{ needs.initialization.outputs.service_name }} + # SERVICE_TYPE: ${{ needs.initialization.outputs.service_type }} + SERVICE_TYPE: "webservice" # e.g., webclient, webservice, winservice + ENVIRONMENT: ${{ needs.initialization.outputs.environment }} + TAG_STRATEGY: ${{ needs.initialization.outputs.tag_strategy }} # Options: "both" (default), "semantic", "commit" + DOCKERFILE_PATH: ${{ needs.initialization.outputs.client_dockerfile_path }} + # VERSION: ${{ needs.initialization.outputs.version }} # Optional: Comment out to skip version suffix + BUILD_ARGS: | + BUILD_VERSION=${{ github.sha }} + ASPNETCORE_ENVIRONMENT=${{ needs.initialization.outputs.environment }} + secrets: + AZURE_CREDENTIALS: ${{ secrets.AZURE_AKS_BLOCKS_CREDENTIALS }} + AZURE_CONTAINER_REGISTRY: ${{ secrets.AZURE_BLOCKS_CONTAINER_REGISTRY }} + ACR_RESOURCE_GROUP: ${{ secrets.ClUSTER_AKS_BLOCKS_RESOURCE_GROUP }} + SELISE_GITHUB_PAT: ${{ secrets.SELISE_GITHUB_PAT }} + + # Update GitOps repository + update-gitops-client: + needs: [ initialization, build-client ] + if: | + github.event_name == 'push' && + always() && + needs.initialization.result == 'success' && + needs.build-client.result == 'success' + uses: SELISEdigitalplatforms/blocks-inventory/.github/workflows/update-gitops-central.yml@main + with: + SERVICE_NAME: ${{ needs.initialization.outputs.service_name }} + SERVICE_TYPE: "webservice" # Match the service type from build job + ENVIRONMENT: ${{ needs.initialization.outputs.environment }} + # VERSION: ${{ needs.initialization.outputs.version }} # Pass version for proper file naming + IMAGE_TAG: ${{ needs.build-client.outputs.image_tag }} + COMMIT_TAG: ${{ needs.build-client.outputs.commit_tag }} + SEMANTIC_TAG: ${{ needs.build-client.outputs.semantic_tag }} + TAG_STRATEGY: ${{ needs.initialization.outputs.tag_strategy }} # Options: "commit" (default), "semantic", or "primary" + CLUSTER_NAME: ${{ needs.initialization.outputs.cluster_name }} + # GITOPS_BRANCH: "main" + secrets: + SELISE_GITHUB_PAT: ${{ secrets.SELISE_GITHUB_PAT }} + AZURE_CONTAINER_REGISTRY: ${{ secrets.AZURE_BLOCKS_CONTAINER_REGISTRY }} + + # Build and push image + build-worker: + # needs: [initialization, sonarqube_server, sonarqube_client, sca_server, sca_client] + needs: [ initialization, sonarqube_server, sonarqube_client ] + # if: ${{ github.event_name == 'push' && (success() || needs.sonarqube_server.result == 'skipped' ) && (success() || needs.sca_server.result == 'skipped' ) }} + if: | + github.event_name == 'push' && + always() && + needs.initialization.result == 'success' && + (needs.sonarqube_server.result == 'success' || needs.sonarqube_server.result == 'skipped') && + (needs.sonarqube_client.result == 'success' || needs.sonarqube_client.result == 'skipped') + uses: SELISEdigitalplatforms/blocks-inventory/.github/workflows/build-push.yml@main + with: + SERVICE_NAME: ${{ needs.initialization.outputs.service_name }} + # SERVICE_TYPE: ${{ needs.initialization.outputs.service_type }} + SERVICE_TYPE: "worker" # e.g., webclient, webservice, winservice + ENVIRONMENT: ${{ needs.initialization.outputs.environment }} + TAG_STRATEGY: ${{ needs.initialization.outputs.tag_strategy }} # Options: "both" (default), "semantic", "commit" + DOCKERFILE_PATH: ${{ needs.initialization.outputs.worker_dockerfile_path }} + # VERSION: ${{ needs.initialization.outputs.version }} # Optional: Comment out to skip version suffix + BUILD_ARGS: | + BUILD_VERSION=${{ github.sha }} + secrets: + AZURE_CREDENTIALS: ${{ secrets.AZURE_AKS_BLOCKS_CREDENTIALS }} + AZURE_CONTAINER_REGISTRY: ${{ secrets.AZURE_BLOCKS_CONTAINER_REGISTRY }} + ACR_RESOURCE_GROUP: ${{ secrets.ClUSTER_AKS_BLOCKS_RESOURCE_GROUP }} + SELISE_GITHUB_PAT: ${{ secrets.SELISE_GITHUB_PAT }} + + # Update GitOps repository + update-gitops-worker: + needs: [ initialization, build-worker ] + if: | + github.event_name == 'push' && + always() && + needs.initialization.result == 'success' && + needs.build-worker.result == 'success' + uses: SELISEdigitalplatforms/blocks-inventory/.github/workflows/update-gitops-central.yml@main + with: + SERVICE_NAME: ${{ needs.initialization.outputs.service_name }} + SERVICE_TYPE: "worker" # Match the service type from build job + ENVIRONMENT: ${{ needs.initialization.outputs.environment }} + # VERSION: ${{ needs.initialization.outputs.version }} # Pass version for proper file naming + IMAGE_TAG: ${{ needs.build-worker.outputs.image_tag }} + COMMIT_TAG: ${{ needs.build-worker.outputs.commit_tag }} + SEMANTIC_TAG: ${{ needs.build-worker.outputs.semantic_tag }} + TAG_STRATEGY: ${{ needs.initialization.outputs.tag_strategy }} # Options: "commit" (default), "semantic", or "primary" + CLUSTER_NAME: ${{ needs.initialization.outputs.cluster_name }} + # GITOPS_BRANCH: "main" + secrets: + SELISE_GITHUB_PAT: ${{ secrets.SELISE_GITHUB_PAT }} + AZURE_CONTAINER_REGISTRY: ${{ secrets.AZURE_BLOCKS_CONTAINER_REGISTRY }} From 1959fc285c63170fea7d4a2e1938d89dafd1758f Mon Sep 17 00:00:00 2001 From: mostafizSelise Date: Fri, 11 Sep 2026 12:34:56 +0200 Subject: [PATCH 5/6] feat(github): push credential + create-repo endpoints; drop the Clone stub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GithubService.Clone returned true without doing anything and nothing called it. Replaced with what the blocks CLI's new `blocks git` family needs: - GET Github/credential — the calling user's stored OAuth token (validated against GitHub on every call) as a git-usable credential; 404 when GitHub is not connected so the CLI can say "reconnect" instead of "auth failed". - POST Github/repos — creates a repository for the user or one of their recorded organisations (auto_init false so the first push is a fast-forward); an org the token doesn't list is refused before GitHub. Tests cover both paths in service and controller (72 Github tests green). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QWP2MoA7tFKfxhx4wwCJct --- server/Api/Controllers/GithubController.cs | 48 ++++++-- .../Interfaces/IVersionControlService.cs | 15 ++- .../Models/Request/CreateRepositoryRequest.cs | 25 ++++ .../Response/GitPushCredentialResponse.cs | 31 +++++ .../Services/GithubService.cs | 90 ++++++++++++++- .../Api/Controllers/GithubControllerTests.cs | 48 ++++++-- .../GithubServiceTests.cs | 108 +++++++++++++++++- 7 files changed, 344 insertions(+), 21 deletions(-) create mode 100644 server/Devops.DomainService/VersionControlSystems/Models/Request/CreateRepositoryRequest.cs create mode 100644 server/Devops.DomainService/VersionControlSystems/Models/Response/GitPushCredentialResponse.cs diff --git a/server/Api/Controllers/GithubController.cs b/server/Api/Controllers/GithubController.cs index 560ac760..298e2657 100644 --- a/server/Api/Controllers/GithubController.cs +++ b/server/Api/Controllers/GithubController.cs @@ -9,6 +9,7 @@ using Devops.DomainService.Shared.Interfaces; using Devops.DomainService.VersionControlSystems.Interfaces; using Devops.DomainService.VersionControlSystems.Models.Request; +using Devops.DomainService.VersionControlSystems.Models.Response; using Devops.DomainService.VersionControlSystems.Services; using Microsoft.AspNetCore.Mvc; namespace Api.Controllers; @@ -106,14 +107,47 @@ public async Task GithubBranchExists([FromQuery] string repoId) }); } - [HttpGet("clone")] - [ProtectedEndPoint("blocks-release::github::clone")] - public async Task Clone([FromQuery] string repo) + /// + /// The calling user's git credential for HTTPS push/fetch — what + /// `blocks git push` and Studio's post-run push authenticate with. + /// 404 when GitHub isn't connected, so the CLI can distinguish "connect + /// GitHub" from a genuine failure. Never logged; the body is the token. + /// + [HttpGet("credential")] + [ProtectedEndPoint("blocks-release::github::credential")] + public async Task GetCredential() { - var result = await _githubService.Clone(repo); - if (result) - return Ok("Successfully cloned repo"); - return BadRequest("Failed to clone repo"); + var credential = await _githubService.GetPushCredential(); + if (credential is null) + { + return NotFound(new BaseApiResponse + { + IsSuccess = false, + Message = "GitHub is not connected for this user, or the connection is no longer valid.", + StatusCode = HttpStatusCode.NotFound, + }); + } + + return Ok(credential); + } + + /// Creates a GitHub repository for a project that has none yet — see . + [HttpPost("repos")] + [ProtectedEndPoint("blocks-release::github::create-repo")] + public async Task CreateRepo([FromBody] CreateRepositoryRequest request) + { + if (request is null || string.IsNullOrWhiteSpace(request.Name)) + { + return BadRequest(new BaseApiResponse { IsSuccess = false, Message = "A repository name is required.", StatusCode = HttpStatusCode.BadRequest }); + } + + var (repo, error) = await _githubService.CreateRepository(request); + if (repo is null) + { + return BadRequest(new BaseApiResponse { IsSuccess = false, Message = error, StatusCode = HttpStatusCode.BadRequest }); + } + + return Ok(new BaseApiResponse { IsSuccess = true, Data = repo, StatusCode = HttpStatusCode.OK }); } [HttpPost("webhook")] diff --git a/server/Devops.DomainService/VersionControlSystems/Interfaces/IVersionControlService.cs b/server/Devops.DomainService/VersionControlSystems/Interfaces/IVersionControlService.cs index f3500aab..1acc44b5 100644 --- a/server/Devops.DomainService/VersionControlSystems/Interfaces/IVersionControlService.cs +++ b/server/Devops.DomainService/VersionControlSystems/Interfaces/IVersionControlService.cs @@ -17,5 +17,18 @@ public interface IVersionControlService public Task SearchUserRepositories(SearchRepositoryListRequest repoSearchQuery); public Task> GetBranches(string repo); public Task<(bool, string)> GetRepoBranchByName(string repo, string branch); - public Task Clone(string repo); + + /// + /// The calling Blocks user's git credential, or null when they have + /// not connected GitHub or the stored token no longer validates. See + /// for what the caller owes it. + /// + public Task GetPushCredential(); + + /// + /// Creates a repository on GitHub for the calling user (or one of their + /// recorded organisations). Returns the repository, or an error message + /// naming why GitHub refused — a name collision is the common one. + /// + public Task<(GithubRepositoryResponse? repo, string? error)> CreateRepository(CreateRepositoryRequest request); } \ No newline at end of file diff --git a/server/Devops.DomainService/VersionControlSystems/Models/Request/CreateRepositoryRequest.cs b/server/Devops.DomainService/VersionControlSystems/Models/Request/CreateRepositoryRequest.cs new file mode 100644 index 00000000..a15a0892 --- /dev/null +++ b/server/Devops.DomainService/VersionControlSystems/Models/Request/CreateRepositoryRequest.cs @@ -0,0 +1,25 @@ +namespace Devops.DomainService.VersionControlSystems.Models.Request; + +/// +/// What a caller needs to say to get a brand-new GitHub repository for a +/// project that has none yet — the `blocks git init` case, where the code +/// exists locally (a Studio workspace, or a `blocks new web` scaffold) and +/// there is nowhere to push it. +/// +public class CreateRepositoryRequest +{ + /// Repository name only — no owner. GitHub derives the slug. + public string Name { get; set; } + + public string? Description { get; set; } + + /// Defaults to private: generated app source is the owner's, not the world's. + public bool Private { get; set; } = true; + + /// + /// Create under this organisation instead of the user's own account. + /// Must be one of the orgs recorded on the stored token, or the call is + /// refused before GitHub is contacted. + /// + public string? Organization { get; set; } +} diff --git a/server/Devops.DomainService/VersionControlSystems/Models/Response/GitPushCredentialResponse.cs b/server/Devops.DomainService/VersionControlSystems/Models/Response/GitPushCredentialResponse.cs new file mode 100644 index 00000000..c8ad7801 --- /dev/null +++ b/server/Devops.DomainService/VersionControlSystems/Models/Response/GitPushCredentialResponse.cs @@ -0,0 +1,31 @@ +namespace Devops.DomainService.VersionControlSystems.Models.Response; + +/// +/// The credential a git client uses to authenticate an HTTPS push or fetch +/// against GitHub on behalf of the calling Blocks user. +/// +/// This is the stored OAuth access token — the decision taken for Studio's +/// background runs was to push as the app owner, and an OAuth-app token is +/// the only credential this integration holds. Two consequences the caller +/// must respect: the token does not expire on its own, and its repo +/// scope reaches every repository the owner can write to, not just the one +/// being pushed. It is therefore handed to git through an askpass/credential +/// helper for the lifetime of one process only, and never written to +/// .git/config, a remote URL, or any file. A GitHub App with +/// installation tokens would remove both caveats; this shape leaves room for +/// that by carrying already. +/// +/// +public class GitPushCredentialResponse +{ + /// Basic-auth username. GitHub accepts any value when the password is a token; this is the conventional one. + public string Username { get; set; } = "x-access-token"; + + public string Token { get; set; } + + /// GitHub login of the account the token belongs to — for `user.name` and for telling the owner whose account is pushing. + public string Login { get; set; } + + /// Null for an OAuth-app token, which never expires until revoked. + public DateTime? ExpiresAt { get; set; } +} diff --git a/server/Devops.DomainService/VersionControlSystems/Services/GithubService.cs b/server/Devops.DomainService/VersionControlSystems/Services/GithubService.cs index 552cccd1..33ec5277 100644 --- a/server/Devops.DomainService/VersionControlSystems/Services/GithubService.cs +++ b/server/Devops.DomainService/VersionControlSystems/Services/GithubService.cs @@ -293,10 +293,96 @@ public async Task> GetBranches(string repo) return (false, null); } - public async Task Clone(string repo) + public async Task GetPushCredential() { + var token = await _tokenRepository.getToken(); + if (token is null || string.IsNullOrWhiteSpace(token.AccessToken)) + { + return null; + } - return true; + // Validated on every call, not trusted from storage: a revoked token + // handed to a git client fails inside `git push` with a message the + // owner can't act on. Failing here instead lets the CLI say + // "reconnect GitHub" rather than "authentication failed". + if (!await ValidateAccessToken(token)) + { + return null; + } + + return new GitPushCredentialResponse + { + Token = token.AccessToken, + Login = token.UserName, + ExpiresAt = null, + }; } + public async Task<(GithubRepositoryResponse? repo, string? error)> CreateRepository(CreateRepositoryRequest request) + { + if (request is null || string.IsNullOrWhiteSpace(request.Name)) + { + return (null, "A repository name is required."); + } + + var token = await _tokenRepository.getToken(); + if (token is null) + { + return (null, "GitHub is not connected for this user."); + } + + // An org the token doesn't list is refused here rather than by + // GitHub, whose 404 for "no access to this org" is indistinguishable + // from "org doesn't exist". + string url; + if (!string.IsNullOrWhiteSpace(request.Organization)) + { + var known = token.Organizations?.Any(o => string.Equals(o.OrgUserName, request.Organization, StringComparison.OrdinalIgnoreCase)) ?? false; + if (!known) + { + return (null, $"Organisation '{request.Organization}' is not one this GitHub account belongs to."); + } + + url = $"{CloudBuildConstants.GITHUB_API_BASE_URI}/orgs/{request.Organization}/repos"; + } + else + { + url = $"{CloudBuildConstants.GITHUB_API_BASE_URI}/user/repos"; + } + + var headers = new Dictionary + { + { "Accept", "application/vnd.github.v3+json" }, + { "User-Agent", "BlocksDevOps" }, + { "Authorization", $"Bearer {token.AccessToken}" }, + }; + + // auto_init deliberately false: the caller already has the code and + // a first commit. A GitHub-made README would give the remote a + // history the local one doesn't share, and the very first push + // would be rejected as non-fast-forward. + var payload = new + { + name = request.Name, + description = request.Description ?? string.Empty, + @private = request.Private, + auto_init = false, + }; + + var (repo, response) = await _httpHelperServices.MakeHttpRequest( + CloudBuildConstants.GITHUB_API_BASE_URI, url, HttpMethod.Post, payload, headers, null); + + if (response.StatusCode == HttpStatusCode.Created && repo is not null) + { + return (repo, null); + } + + return response.StatusCode switch + { + HttpStatusCode.UnprocessableEntity => (null, $"GitHub refused to create '{request.Name}' — a repository with that name probably already exists."), + HttpStatusCode.Unauthorized => (null, "GitHub rejected the stored token. Reconnect GitHub and try again."), + HttpStatusCode.Forbidden => (null, "The connected GitHub account is not allowed to create repositories here."), + _ => (null, $"GitHub returned {(int)response.StatusCode} while creating the repository."), + }; + } } \ No newline at end of file diff --git a/server/XUnitTest/Api/Controllers/GithubControllerTests.cs b/server/XUnitTest/Api/Controllers/GithubControllerTests.cs index 5bb08156..7cdf6d60 100644 --- a/server/XUnitTest/Api/Controllers/GithubControllerTests.cs +++ b/server/XUnitTest/Api/Controllers/GithubControllerTests.cs @@ -108,20 +108,54 @@ public async Task GithubBranchExists_RepoFound_ReturnsBranchResult() body.IsSuccess.Should().BeTrue(); } - // ---- Clone ---- + // ---- GetCredential ---- [Fact] - public async Task Clone_Success_ReturnsOk() + public async Task GetCredential_Connected_ReturnsOkWithCredential() { - _github.Setup(g => g.Clone("repo")).ReturnsAsync(true); - (await CreateController().Clone("repo")).Should().BeOfType(); + _github.Setup(g => g.GetPushCredential()).ReturnsAsync(new GitPushCredentialResponse { Token = "tok", Login = "octo" }); + var result = await CreateController().GetCredential(); + result.Should().BeOfType() + .Which.Value.Should().BeOfType() + .Which.Token.Should().Be("tok"); } [Fact] - public async Task Clone_Failure_ReturnsBadRequest() + public async Task GetCredential_NotConnected_ReturnsNotFound_SoTheCliCanSayReconnect() { - _github.Setup(g => g.Clone("repo")).ReturnsAsync(false); - (await CreateController().Clone("repo")).Should().BeOfType(); + _github.Setup(g => g.GetPushCredential()).ReturnsAsync((GitPushCredentialResponse)null); + (await CreateController().GetCredential()).Should().BeOfType(); + } + + // ---- CreateRepo ---- + + [Fact] + public async Task CreateRepo_NoName_ReturnsBadRequest_WithoutCallingGithub() + { + (await CreateController().CreateRepo(new CreateRepositoryRequest { Name = "" })).Should().BeOfType(); + _github.Verify(g => g.CreateRepository(It.IsAny()), Times.Never); + } + + [Fact] + public async Task CreateRepo_Created_ReturnsOkWithRepo() + { + _github.Setup(g => g.CreateRepository(It.IsAny())) + .ReturnsAsync((new GithubRepositoryResponse { fullName = "octo/app" }, (string)null)); + var result = await CreateController().CreateRepo(new CreateRepositoryRequest { Name = "app" }); + var body = result.Should().BeOfType().Which.Value.Should().BeOfType().Which; + body.IsSuccess.Should().BeTrue(); + body.Data.Should().BeOfType().Which.fullName.Should().Be("octo/app"); + } + + [Fact] + public async Task CreateRepo_GithubRefused_ReturnsBadRequestWithTheReason() + { + _github.Setup(g => g.CreateRepository(It.IsAny())) + .ReturnsAsync(((GithubRepositoryResponse)null, "already exists")); + var result = await CreateController().CreateRepo(new CreateRepositoryRequest { Name = "app" }); + result.Should().BeOfType() + .Which.Value.Should().BeOfType() + .Which.Message.Should().Contain("already exists"); } // ---- CreateWebhook ---- diff --git a/server/XUnitTest/Devops/VersionControlSystems/GithubServiceTests.cs b/server/XUnitTest/Devops/VersionControlSystems/GithubServiceTests.cs index 4c2c15a4..f0625e4a 100644 --- a/server/XUnitTest/Devops/VersionControlSystems/GithubServiceTests.cs +++ b/server/XUnitTest/Devops/VersionControlSystems/GithubServiceTests.cs @@ -349,13 +349,113 @@ public async Task GetRepoBranchByName_OtherStatus_ReturnsFalseNull() msg.Should().BeNull(); } - // ---- Clone ---- + // ---- GetPushCredential ---- + + private void ValidationReturns(HttpStatusCode code) => + _http.Setup(h => h.MakeHttpRequest(It.IsAny(), It.IsAny(), HttpMethod.Get, null, It.IsAny>(), null)) + .ReturnsAsync(((object)null, Resp(code))); [Fact] - public async Task Clone_ReturnsTrue() + public async Task GetPushCredential_NoToken_ReturnsNull() { - var result = await CreateService().Clone("org/repo"); - result.Should().BeTrue(); + _tokenRepo.Setup(t => t.getToken()).ReturnsAsync((RepositoryToken)null); + (await CreateService().GetPushCredential()).Should().BeNull(); + } + + [Fact] + public async Task GetPushCredential_RevokedToken_ReturnsNull_NotAStaleCredential() + { + // A revoked token handed to git fails inside `git push`; failing + // here is what lets the CLI say "reconnect GitHub" instead. + _tokenRepo.Setup(t => t.getToken()).ReturnsAsync(Token()); + ValidationReturns(HttpStatusCode.Unauthorized); + (await CreateService().GetPushCredential()).Should().BeNull(); + } + + [Fact] + public async Task GetPushCredential_ValidToken_ReturnsTokenAndLogin() + { + _tokenRepo.Setup(t => t.getToken()).ReturnsAsync(Token()); + ValidationReturns(HttpStatusCode.OK); + + var credential = await CreateService().GetPushCredential(); + + credential.Should().NotBeNull(); + credential.Token.Should().Be("tok"); + credential.Login.Should().Be("octo"); + credential.Username.Should().Be("x-access-token"); + credential.ExpiresAt.Should().BeNull("an OAuth-app token has no expiry of its own"); + } + + // ---- CreateRepository ---- + + private void CreateReturns(HttpStatusCode code, GithubRepositoryResponse body = null) => + _http.Setup(h => h.MakeHttpRequest(It.IsAny(), It.IsAny(), HttpMethod.Post, It.IsAny(), It.IsAny>(), null)) + .ReturnsAsync((body, Resp(code))); + + [Fact] + public async Task CreateRepository_NoName_FailsBeforeGithub() + { + var (repo, error) = await CreateService().CreateRepository(new CreateRepositoryRequest { Name = " " }); + repo.Should().BeNull(); + error.Should().Contain("name is required"); + _http.VerifyNoOtherCalls(); + } + + [Fact] + public async Task CreateRepository_NoToken_SaysNotConnected() + { + _tokenRepo.Setup(t => t.getToken()).ReturnsAsync((RepositoryToken)null); + var (repo, error) = await CreateService().CreateRepository(new CreateRepositoryRequest { Name = "app" }); + repo.Should().BeNull(); + error.Should().Contain("not connected"); + } + + [Fact] + public async Task CreateRepository_Created_ReturnsRepo_PostedToUserRepos() + { + _tokenRepo.Setup(t => t.getToken()).ReturnsAsync(Token()); + CreateReturns(HttpStatusCode.Created, new GithubRepositoryResponse { fullName = "octo/app", url = "https://github.com/octo/app" }); + + var (repo, error) = await CreateService().CreateRepository(new CreateRepositoryRequest { Name = "app" }); + + error.Should().BeNull(); + repo.fullName.Should().Be("octo/app"); + _http.Verify(h => h.MakeHttpRequest(It.IsAny(), It.Is(u => u.EndsWith("/user/repos")), HttpMethod.Post, It.IsAny(), It.IsAny>(), null), Times.Once); + } + + [Fact] + public async Task CreateRepository_KnownOrg_PostsToOrgRepos() + { + _tokenRepo.Setup(t => t.getToken()).ReturnsAsync(Token()); + CreateReturns(HttpStatusCode.Created, new GithubRepositoryResponse { fullName = "myorg/app" }); + + var (repo, _) = await CreateService().CreateRepository(new CreateRepositoryRequest { Name = "app", Organization = "myorg" }); + + repo.Should().NotBeNull(); + _http.Verify(h => h.MakeHttpRequest(It.IsAny(), It.Is(u => u.EndsWith("/orgs/myorg/repos")), HttpMethod.Post, It.IsAny(), It.IsAny>(), null), Times.Once); + } + + [Fact] + public async Task CreateRepository_UnknownOrg_RefusedBeforeGithub() + { + // GitHub's 404 for "no access" and "doesn't exist" are the same; + // refusing here gives the owner a message they can act on. + _tokenRepo.Setup(t => t.getToken()).ReturnsAsync(Token()); + var (repo, error) = await CreateService().CreateRepository(new CreateRepositoryRequest { Name = "app", Organization = "someone-else" }); + repo.Should().BeNull(); + error.Should().Contain("someone-else"); + _http.VerifyNoOtherCalls(); + } + + [Fact] + public async Task CreateRepository_NameTaken_ExplainsTheCollision() + { + _tokenRepo.Setup(t => t.getToken()).ReturnsAsync(Token()); + CreateReturns(HttpStatusCode.UnprocessableEntity); + var (repo, error) = await CreateService().CreateRepository(new CreateRepositoryRequest { Name = "app" }); + repo.Should().BeNull(); + error.Should().Contain("already exists"); } } } From 4c38afbf6d7b6fd571f04b00ff4c2e5c651aa11c Mon Sep 17 00:00:00 2001 From: asifrafeen Date: Sun, 13 Sep 2026 19:14:19 +0600 Subject: [PATCH 6/6] package update --- client/package-lock.json | 8 ++++---- client/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index 1559d82d..641194ab 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -34,7 +34,7 @@ "@radix-ui/react-tabs": "^1.1.0", "@radix-ui/react-toast": "^1.2.2", "@radix-ui/react-tooltip": "^1.1.2", - "@seliseblocks/genesis-os": "^4.3.4", + "@seliseblocks/genesis-os": "^4.3.7", "@tanstack/react-query": "^5.62.11", "@tanstack/react-query-devtools": "^5.62.11", "@tanstack/react-table": "^8.20.5", @@ -3263,9 +3263,9 @@ "license": "MIT" }, "node_modules/@seliseblocks/genesis-os": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/@seliseblocks/genesis-os/-/genesis-os-4.3.4.tgz", - "integrity": "sha512-cmxM+Mr5GNP0kVy1es9Yowt1qUCtxCzQHuaqKEYmX3e/8A0pwZJ3SUZwvas+/aPnIB2ewkbFipskFH+wmudK6Q==", + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/@seliseblocks/genesis-os/-/genesis-os-4.3.7.tgz", + "integrity": "sha512-Tw+/N6BaJCRPnG1vpvC/ijmBfeC9PEjGHVMeBj8dGiwem/BNHE6dWWGMmagR9Uvodlbe76QhSjnSJwWZWhHqyg==", "license": "MIT", "dependencies": { "@dnd-kit/core": "^6.3.1", diff --git a/client/package.json b/client/package.json index 2f5f7e4c..5329b770 100644 --- a/client/package.json +++ b/client/package.json @@ -50,7 +50,7 @@ "@radix-ui/react-tabs": "^1.1.0", "@radix-ui/react-toast": "^1.2.2", "@radix-ui/react-tooltip": "^1.1.2", - "@seliseblocks/genesis-os": "^4.3.4", + "@seliseblocks/genesis-os": "^4.3.7", "@tanstack/react-query": "^5.62.11", "@tanstack/react-query-devtools": "^5.62.11", "@tanstack/react-table": "^8.20.5",