diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml new file mode 100644 index 000000000..23cfb16ea --- /dev/null +++ b/.github/workflows/docker-image.yml @@ -0,0 +1,134 @@ +name: Build, Tag, and Push Docker Image + +on: + workflow_dispatch: + inputs: + tag_version: + description: 'Docker image tag version (e.g., 3.3.11)' + required: true + branch: + description: 'Branch to build from (default: staging)' + required: false + default: 'staging' + +env: + DOCKER_IMAGE_NAME: elevate-user + DOCKER_REGISTRY: docker.io + DOCKER_NAMESPACE: shikshalokamqa + +permissions: + contents: write + +jobs: + docker-image-build-and-push: + runs-on: ubuntu-latest + + steps: + - name: Checkout code from target branch + uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.branch || 'staging' }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Get Docker tag version + id: get-version + shell: bash + run: | + VERSION="${{ github.event.inputs.tag_version }}" + # Strip leading "v" if present + VERSION="${VERSION#v}" + # Enforce x.y.z or x.y.z-rc.1 format + if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]]; then + echo "Error: tag_version must look like 3.4.0 or 3.4.0-rc.1 (no other suffixes allowed)" + exit 1 + fi + printf 'version=%s\n' "$VERSION" >> "$GITHUB_OUTPUT" + + - name: Check if Git tag already exists + run: | + VERSION="${{ steps.get-version.outputs.version }}" + git fetch --tags + if git rev-parse "$VERSION" >/dev/null 2>&1; then + echo "Error: Git tag $VERSION already exists" + exit 1 + fi + + - name: Check if version exists on Docker Hub + run: | + VERSION="${{ steps.get-version.outputs.version }}" + + LOGIN_RESPONSE=$(curl -s -w "\n%{http_code}" \ + -X POST \ + -H "Content-Type: application/json" \ + -d '{"username": "${{ secrets.DOCKERHUB_USERNAME }}", "password": "${{ secrets.DOCKERHUB_TOKEN }}"}' \ + "https://hub.docker.com/v2/users/login") + LOGIN_HTTP=$(echo "$LOGIN_RESPONSE" | tail -n1) + LOGIN_BODY=$(echo "$LOGIN_RESPONSE" | head -n-1) + + if [ "$LOGIN_HTTP" -ne 200 ]; then + echo "Error: Docker Hub login failed with HTTP $LOGIN_HTTP" + exit 1 + fi + + TOKEN=$(echo "$LOGIN_BODY" | jq -r .token) + if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then + echo "Error: Docker Hub login succeeded but returned no token" + exit 1 + fi + + RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" \ + -H "Authorization: Bearer $TOKEN" \ + "https://hub.docker.com/v2/namespaces/${{ env.DOCKER_NAMESPACE }}/repositories/${{ env.DOCKER_IMAGE_NAME }}/tags/$VERSION") + + if [ "$RESPONSE" -eq 200 ]; then + echo "Error: Tag $VERSION already exists on Docker Hub" + exit 1 + elif [ "$RESPONSE" -eq 404 ]; then + echo "Tag $VERSION not found on Docker Hub — safe to push" + else + echo "Error: Unexpected HTTP $RESPONSE from Docker Hub tag check; aborting to fail safe" + exit 1 + fi + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.DOCKER_REGISTRY }}/${{ env.DOCKER_NAMESPACE }}/${{ env.DOCKER_IMAGE_NAME }} + tags: | + type=raw,value=${{ steps.get-version.outputs.version }} + + - name: Build and push Docker image + id: build + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + platforms: linux/amd64,linux/arm64 + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Create and push Git tag + run: | + VERSION="${{ steps.get-version.outputs.version }}" + git config user.name "github-actions" + git config user.email "github-actions@github.com" + git tag -a "$VERSION" -m "Release $VERSION" + git push origin "$VERSION" + + - name: Job summary + run: | + echo "### Docker Image Published 🚀" >> $GITHUB_STEP_SUMMARY + echo "**Tag:** ${{ steps.get-version.outputs.version }}" >> $GITHUB_STEP_SUMMARY + echo "**Digest:** ${{ steps.build.outputs.digest }}" >> $GITHUB_STEP_SUMMARY \ No newline at end of file diff --git a/.github/workflows/prod-release.yml b/.github/workflows/prod-release.yml new file mode 100644 index 000000000..913838020 --- /dev/null +++ b/.github/workflows/prod-release.yml @@ -0,0 +1,162 @@ +name: Promote RC to Production Release + +on: + workflow_dispatch: + inputs: + rc_tag: + description: "Approved RC tag (example: 3.4.0-rc.2)" + required: true + +env: + DOCKER_IMAGE_NAME: elevate-user + DOCKER_REGISTRY: docker.io + DOCKER_NAMESPACE: shikshalokamqa + +permissions: + contents: write + +concurrency: + group: promote + cancel-in-progress: false + +jobs: + promote: + runs-on: ubuntu-latest + + steps: + - name: Validate RC tag + id: version + env: + RC_TAG: ${{ github.event.inputs.rc_tag }} + run: | + if ! [[ "$RC_TAG" =~ ^([0-9]+\.[0-9]+\.[0-9]+)-rc\.[0-9]+$ ]]; then + echo "Invalid rc_tag format. Must look like 3.4.0-rc.2" + exit 1 + fi + RELEASE_TAG=$(echo "$RC_TAG" | sed 's/-rc\.[0-9]*//') + echo "rc=$RC_TAG" >> $GITHUB_OUTPUT + echo "release=$RELEASE_TAG" >> $GITHUB_OUTPUT + + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Verify RC commit exists in master + id: verify + run: | + RC_TAG="${{ steps.version.outputs.rc }}" + git fetch origin master --tags + RC_COMMIT=$(git rev-list -n 1 "$RC_TAG") + if git merge-base --is-ancestor "$RC_COMMIT" origin/master; then + echo "RC commit is present in master — safe to promote." + else + echo "Error: RC tag commit is NOT in master." + echo "Merge staging → master before promoting." + exit 1 + fi + echo "rc_commit=$RC_COMMIT" >> $GITHUB_OUTPUT + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Check RC image exists and production tag is absent on Docker Hub + run: | + RC_TAG="${{ steps.version.outputs.rc }}" + RELEASE_TAG="${{ steps.version.outputs.release }}" + + # FIX 1: Build JSON safely with jq --arg to avoid special-character injection + LOGIN_RESPONSE=$(jq -n \ + --arg username "${{ secrets.DOCKERHUB_USERNAME }}" \ + --arg password "${{ secrets.DOCKERHUB_TOKEN }}" \ + '{"username": $username, "password": $password}' \ + | curl -s -w "\n%{http_code}" \ + -X POST \ + -H "Content-Type: application/json" \ + -d @- \ + "https://hub.docker.com/v2/users/login") + LOGIN_HTTP=$(echo "$LOGIN_RESPONSE" | tail -n1) + LOGIN_BODY=$(echo "$LOGIN_RESPONSE" | head -n-1) + + if [ "$LOGIN_HTTP" -ne 200 ]; then + echo "Error: Docker Hub login failed with HTTP $LOGIN_HTTP" + exit 1 + fi + + TOKEN=$(echo "$LOGIN_BODY" | jq -r .token) + if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then + echo "Error: Docker Hub login succeeded but returned no token" + exit 1 + fi + + # Verify RC image exists + RC_RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" \ + -H "Authorization: Bearer $TOKEN" \ + "https://hub.docker.com/v2/namespaces/${{ env.DOCKER_NAMESPACE }}/repositories/${{ env.DOCKER_IMAGE_NAME }}/tags/$RC_TAG") + if [ "$RC_RESPONSE" -eq 404 ]; then + echo "Error: RC image $RC_TAG not found on Docker Hub" + exit 1 + elif [ "$RC_RESPONSE" -ne 200 ]; then + echo "Error: Unexpected HTTP $RC_RESPONSE checking RC image — aborting to fail safe" + exit 1 + fi + echo "RC image $RC_TAG confirmed on Docker Hub." + + # Verify production tag does not already exist + PROD_RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" \ + -H "Authorization: Bearer $TOKEN" \ + "https://hub.docker.com/v2/namespaces/${{ env.DOCKER_NAMESPACE }}/repositories/${{ env.DOCKER_IMAGE_NAME }}/tags/$RELEASE_TAG") + if [ "$PROD_RESPONSE" -eq 200 ]; then + echo "Error: Production tag $RELEASE_TAG already exists on Docker Hub" + exit 1 + elif [ "$PROD_RESPONSE" -ne 404 ]; then + echo "Error: Unexpected HTTP $PROD_RESPONSE checking production tag — aborting to fail safe" + exit 1 + fi + echo "Production tag $RELEASE_TAG is absent — safe to promote." + + # FIX 2: Replace pull/tag/push with imagetools create to preserve multi-arch manifest + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Retag RC image as production (multi-arch) + run: | + docker buildx imagetools create \ + --tag ${{ env.DOCKER_REGISTRY }}/${{ env.DOCKER_NAMESPACE }}/${{ env.DOCKER_IMAGE_NAME }}:${{ steps.version.outputs.release }} \ + ${{ env.DOCKER_REGISTRY }}/${{ env.DOCKER_NAMESPACE }}/${{ env.DOCKER_IMAGE_NAME }}:${{ steps.version.outputs.rc }} + + - name: Create production Git tag + run: | + VERSION="${{ steps.version.outputs.release }}" + git config user.name "github-actions" + git config user.email "github-actions@github.com" + git fetch --tags + if git rev-parse "$VERSION" >/dev/null 2>&1; then + echo "Error: Git tag $VERSION already exists" + exit 1 + fi + git tag -a "$VERSION" "${{ steps.verify.outputs.rc_commit }}" -m "Release $VERSION" + git push origin "$VERSION" + + - name: Job summary + run: | + DIGEST=$(docker buildx imagetools inspect \ + ${{ env.DOCKER_REGISTRY }}/${{ env.DOCKER_NAMESPACE }}/${{ env.DOCKER_IMAGE_NAME }}:${{ steps.version.outputs.release }} \ + --format '{{json .Manifest}}' \ + | jq -r '.digest') + { + echo "### Production Promotion Complete 🚀" + echo "" + echo "| Field | Value |" + echo "| --- | --- |" + echo "| **RC tag** | \`${{ steps.version.outputs.rc }}\` |" + echo "| **Release tag** | \`${{ steps.version.outputs.release }}\` |" + echo "| **Validated commit** | \`${{ steps.verify.outputs.rc_commit }}\` |" + echo "| **Docker image** | \`${{ env.DOCKER_REGISTRY }}/${{ env.DOCKER_NAMESPACE }}/${{ env.DOCKER_IMAGE_NAME }}:${{ steps.version.outputs.release }}\` |" + echo "| **Digest** | \`$DIGEST\` |" + echo "| **Triggered by** | @${{ github.actor }} |" + echo "| **Run** | [${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) |" + } >> $GITHUB_STEP_SUMMARY \ No newline at end of file