diff --git a/.gitea/workflows/prod-tag-deploy.yml b/.gitea/workflows/prod-tag-deploy.yml index 4c1e85a..1dab77d 100644 --- a/.gitea/workflows/prod-tag-deploy.yml +++ b/.gitea/workflows/prod-tag-deploy.yml @@ -101,15 +101,34 @@ jobs: $pass.Trim() | docker login "${{ vars.REGISTRY_HOST }}" -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Resolve node:20-alpine (Gitea mirror, then local, then Hub) + run: | + $ErrorActionPreference = 'Stop' + $prefix = "${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_OWNER }}".Trim() + powershell -NoProfile -ExecutionPolicy Bypass -File .\infrastructure\scripts\ci-resolve-node-image.ps1 -RegistryPrefix $prefix + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Build and push backend (tag only, not :latest) run: | $ErrorActionPreference = 'Stop' $dispatchTag = '${{ github.event.inputs.tag }}'.Trim() if (-not [string]::IsNullOrWhiteSpace($dispatchTag)) { $tag = $dispatchTag } else { $tag = "${{ github.ref_name }}" } $prefix = "${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_OWNER }}".Trim() - Write-Host "Building $prefix/dyolink-backend:$tag" - docker build -t "$prefix/dyolink-backend:$tag" ./backend - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $nodeImage = ([System.IO.File]::ReadAllText((Join-Path (Get-Location) '.ci-node-image'))).Trim() + if ([string]::IsNullOrWhiteSpace($nodeImage)) { + Write-Host "Missing .ci-node-image" + exit 1 + } + if (Test-Path '.ci-use-legacy-builder') { $env:DOCKER_BUILDKIT = '0' } + Write-Host "Building $prefix/dyolink-backend:$tag (NODE_IMAGE=$nodeImage)" + $ok = $false + for ($i = 1; $i -le 3; $i++) { + Write-Host "docker build attempt $i/3" + docker build --build-arg "NODE_IMAGE=$nodeImage" -t "$prefix/dyolink-backend:$tag" ./backend + if ($LASTEXITCODE -eq 0) { $ok = $true; break } + if ($i -lt 3) { Start-Sleep -Seconds (20 * $i) } + } + if (-not $ok) { exit 1 } docker push "$prefix/dyolink-backend:$tag" if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } @@ -122,19 +141,32 @@ jobs: $dispatchTag = '${{ github.event.inputs.tag }}'.Trim() if (-not [string]::IsNullOrWhiteSpace($dispatchTag)) { $tag = $dispatchTag } else { $tag = "${{ github.ref_name }}" } $prefix = "${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_OWNER }}".Trim() + $nodeImage = ([System.IO.File]::ReadAllText((Join-Path (Get-Location) '.ci-node-image'))).Trim() + if ([string]::IsNullOrWhiteSpace($nodeImage)) { + Write-Host "Missing .ci-node-image" + exit 1 + } + if (Test-Path '.ci-use-legacy-builder') { $env:DOCKER_BUILDKIT = '0' } $base = $env:PROD_PUBLIC_BASE_URL.Trim() if ([string]::IsNullOrWhiteSpace($base)) { $base = 'https://nudentic.ir' } $base = $base.TrimEnd('/') - Write-Host "Building $prefix/dyolink-frontend:$tag" - docker build ` - --build-arg "NEXT_PUBLIC_API_URL=$base/api" ` - --build-arg "NEXT_PUBLIC_APP_URL=$base" ` - --build-arg "NEXT_PUBLIC_APP_NAME=Dyolink" ` - --build-arg "NEXT_PUBLIC_SENTRY_DSN=$env:NEXT_PUBLIC_SENTRY_DSN" ` - --build-arg "NEXT_PUBLIC_SENTRY_ENVIRONMENT=production" ` - -t "$prefix/dyolink-frontend:$tag" ` - ./frontend - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + Write-Host "Building $prefix/dyolink-frontend:$tag (NODE_IMAGE=$nodeImage)" + $ok = $false + for ($i = 1; $i -le 3; $i++) { + Write-Host "docker build attempt $i/3" + docker build ` + --build-arg "NEXT_PUBLIC_API_URL=$base/api" ` + --build-arg "NEXT_PUBLIC_APP_URL=$base" ` + --build-arg "NEXT_PUBLIC_APP_NAME=Dyolink" ` + --build-arg "NEXT_PUBLIC_SENTRY_DSN=$env:NEXT_PUBLIC_SENTRY_DSN" ` + --build-arg "NEXT_PUBLIC_SENTRY_ENVIRONMENT=production" ` + --build-arg "NODE_IMAGE=$nodeImage" ` + -t "$prefix/dyolink-frontend:$tag" ` + ./frontend + if ($LASTEXITCODE -eq 0) { $ok = $true; break } + if ($i -lt 3) { Start-Sleep -Seconds (20 * $i) } + } + if (-not $ok) { exit 1 } docker push "$prefix/dyolink-frontend:$tag" if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/.gitea/workflows/registry-build-deploy.yml b/.gitea/workflows/registry-build-deploy.yml index 29f12de..d00c527 100644 --- a/.gitea/workflows/registry-build-deploy.yml +++ b/.gitea/workflows/registry-build-deploy.yml @@ -84,6 +84,13 @@ jobs: $pass.Trim() | docker login "${{ vars.REGISTRY_HOST }}" -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Resolve node:20-alpine (Gitea mirror, then local, then Hub) + run: | + $ErrorActionPreference = 'Stop' + $prefix = "${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_OWNER }}".Trim() + powershell -NoProfile -ExecutionPolicy Bypass -File .\infrastructure\scripts\ci-resolve-node-image.ps1 -RegistryPrefix $prefix + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Build and push backend run: | $ErrorActionPreference = 'Stop' @@ -93,12 +100,25 @@ jobs: Write-Host "Missing github.sha, REGISTRY_HOST, or REGISTRY_OWNER" exit 1 } - Write-Host "Building $prefix/dyolink-backend:$tag" - docker build ` - -t "$prefix/dyolink-backend:$tag" ` - -t "$prefix/dyolink-backend:latest" ` - ./backend - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $nodeImage = ([System.IO.File]::ReadAllText((Join-Path (Get-Location) '.ci-node-image'))).Trim() + if ([string]::IsNullOrWhiteSpace($nodeImage)) { + Write-Host "Missing .ci-node-image" + exit 1 + } + if (Test-Path '.ci-use-legacy-builder') { $env:DOCKER_BUILDKIT = '0' } + Write-Host "Building $prefix/dyolink-backend:$tag (NODE_IMAGE=$nodeImage)" + $ok = $false + for ($i = 1; $i -le 3; $i++) { + Write-Host "docker build attempt $i/3" + docker build ` + --build-arg "NODE_IMAGE=$nodeImage" ` + -t "$prefix/dyolink-backend:$tag" ` + -t "$prefix/dyolink-backend:latest" ` + ./backend + if ($LASTEXITCODE -eq 0) { $ok = $true; break } + if ($i -lt 3) { Start-Sleep -Seconds (20 * $i) } + } + if (-not $ok) { exit 1 } docker push "$prefix/dyolink-backend:$tag" if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } docker push "$prefix/dyolink-backend:latest" @@ -113,17 +133,30 @@ jobs: $tag = "${{ github.sha }}".Substring(0, 7) $prefix = "${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_OWNER }}".Trim() $base = $env:PUBLIC_BASE_URL - Write-Host "Building $prefix/dyolink-frontend:$tag" - docker build ` - --build-arg "NEXT_PUBLIC_API_URL=$base/api" ` - --build-arg "NEXT_PUBLIC_APP_URL=$base" ` - --build-arg "NEXT_PUBLIC_APP_NAME=Dyolink" ` - --build-arg "NEXT_PUBLIC_SENTRY_DSN=$env:NEXT_PUBLIC_SENTRY_DSN" ` - --build-arg "NEXT_PUBLIC_SENTRY_ENVIRONMENT=staging" ` - -t "$prefix/dyolink-frontend:$tag" ` - -t "$prefix/dyolink-frontend:latest" ` - ./frontend - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $nodeImage = ([System.IO.File]::ReadAllText((Join-Path (Get-Location) '.ci-node-image'))).Trim() + if ([string]::IsNullOrWhiteSpace($nodeImage)) { + Write-Host "Missing .ci-node-image" + exit 1 + } + if (Test-Path '.ci-use-legacy-builder') { $env:DOCKER_BUILDKIT = '0' } + Write-Host "Building $prefix/dyolink-frontend:$tag (NODE_IMAGE=$nodeImage)" + $ok = $false + for ($i = 1; $i -le 3; $i++) { + Write-Host "docker build attempt $i/3" + docker build ` + --build-arg "NEXT_PUBLIC_API_URL=$base/api" ` + --build-arg "NEXT_PUBLIC_APP_URL=$base" ` + --build-arg "NEXT_PUBLIC_APP_NAME=Dyolink" ` + --build-arg "NEXT_PUBLIC_SENTRY_DSN=$env:NEXT_PUBLIC_SENTRY_DSN" ` + --build-arg "NEXT_PUBLIC_SENTRY_ENVIRONMENT=staging" ` + --build-arg "NODE_IMAGE=$nodeImage" ` + -t "$prefix/dyolink-frontend:$tag" ` + -t "$prefix/dyolink-frontend:latest" ` + ./frontend + if ($LASTEXITCODE -eq 0) { $ok = $true; break } + if ($i -lt 3) { Start-Sleep -Seconds (20 * $i) } + } + if (-not $ok) { exit 1 } docker push "$prefix/dyolink-frontend:$tag" if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } docker push "$prefix/dyolink-frontend:latest" diff --git a/backend/Dockerfile b/backend/Dockerfile index f381da1..1ab9c07 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,7 +1,10 @@ +# CI can pass a Gitea-hosted mirror when Docker Hub TLS fails (see ci-resolve-node-image.ps1). +ARG NODE_IMAGE=node:20-alpine + # ============================================ # STAGE 1: BUILDER STAGE # ============================================ -FROM node:20-alpine AS builder +FROM ${NODE_IMAGE} AS builder WORKDIR /app @@ -30,7 +33,7 @@ RUN npm prune --omit=dev # ============================================ # STAGE 2: PRODUCTION STAGE # ============================================ -FROM node:20-alpine +FROM ${NODE_IMAGE} RUN apk add --no-cache dumb-init diff --git a/frontend/Dockerfile b/frontend/Dockerfile index bb5ddab..f5b50e0 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,5 +1,8 @@ +# CI can pass a Gitea-hosted mirror when Docker Hub TLS fails (see ci-resolve-node-image.ps1). +ARG NODE_IMAGE=node:20-alpine + # Build stage — produces `.next/standalone` (see next.config.ts output: standalone) -FROM node:20-alpine AS builder +FROM ${NODE_IMAGE} AS builder WORKDIR /app @@ -32,7 +35,7 @@ ENV NEXT_PUBLIC_SENTRY_ENVIRONMENT=${NEXT_PUBLIC_SENTRY_ENVIRONMENT} RUN npm run build # Production — minimal runtime using Next.js standalone bundle -FROM node:20-alpine AS runner +FROM ${NODE_IMAGE} AS runner RUN apk add --no-cache dumb-init diff --git a/infrastructure/STAGING-DEPLOY.md b/infrastructure/STAGING-DEPLOY.md index c20eb8a..cd378d9 100644 --- a/infrastructure/STAGING-DEPLOY.md +++ b/infrastructure/STAGING-DEPLOY.md @@ -300,7 +300,7 @@ On the Windows host, from repo `infrastructure/`: | Runner can't register on public IP | Use `http://127.0.0.1:3000` for `--instance` | | Variable name rejected in Gitea | No `GITEA_*` / `GITHUB_*` prefixes; use `CLONE_HOST` | | `413 Request Entity Too Large` on `docker push` to `https://gitea.wixur.ir/v2/…/blobs/uploads` | Nginx (or Cloudflare) in front of Gitea is rejecting the image layer. **Fix the proxy** (then `nginx -s reload`): in the `server { server_name gitea.wixur.ir; }` block set `client_max_body_size 0;` and `proxy_request_buffering off;` — snippet: [`nginx/windows-gitea.wixur.snippet.conf`](nginx/windows-gitea.wixur.snippet.conf). **Or skip the proxy:** set `REGISTRY_HOST=host.docker.internal:3000` (and Gitea `ROOT_URL`) so CI pushes to `:3000`. If the hostname is orange-clouded on Cloudflare, grey-cloud it (free plan caps uploads at 100MB). | -| `…-backend:: not found` / `…-frontend:: not found` on compose pull | Images were never pushed. `build-and-push` can look green in ~40s because Windows PowerShell ignores `docker` exit codes — check that job’s **Build and push** logs for a real `docker build`/`docker push` failure (often 413 via `gitea.wixur.ir`). Confirm the SHA tag exists under Gitea **Packages**. Prefer `REGISTRY_HOST=host.docker.internal:3000`. Re-run the workflow after the push actually succeeds. | +| `TLS handshake timeout` to `registry-1.docker.io` / `node:20-alpine` | Docker Hub unreachable from the Windows runner. CI mirrors `node:20-alpine` into Gitea (`/node:20-alpine`) and builds from that. If Hub is down **and** the image is not on the machine: on the runner run `docker pull node:20-alpine` when Hub works, then re-run the workflow. Optional Docker Engine `registry-mirrors`. | | `docker login` connection refused on `127.0.0.1:3000` | **Docker Desktop on Windows:** set `REGISTRY_HOST=host.docker.internal:3000`, add it to insecure-registries, set Gitea `ROOT_URL=http://host.docker.internal:3000/`. Keep `CLONE_HOST=127.0.0.1:3000` for git. | | `docker login` / push denied, redirect to public IP | Set Gitea `ROOT_URL` to a host Docker can reach (`host.docker.internal:3000` on Windows Docker Desktop). | | `server gave HTTP response to HTTPS client` | Add registry host to Docker **insecure-registries**, restart Docker | @@ -331,6 +331,7 @@ docker logs dyolink_frontend_staging --tail 50 | Path | Role | |------|------| | `.gitea/workflows/registry-build-deploy.yml` | CI: build, push, deploy | +| `infrastructure/scripts/ci-resolve-node-image.ps1` | CI: cache `node:20-alpine` on Gitea so builds do not depend on Docker Hub | | `infrastructure/docker-compose.registry.yml` | Staging stack (pull-only images) | | `infrastructure/deploy.registry.env.example` | Manual deploy env template | | `infrastructure/database.staging.env.example` | Postgres secrets template | diff --git a/infrastructure/scripts/ci-resolve-node-image.ps1 b/infrastructure/scripts/ci-resolve-node-image.ps1 new file mode 100644 index 0000000..80d9aa3 --- /dev/null +++ b/infrastructure/scripts/ci-resolve-node-image.ps1 @@ -0,0 +1,68 @@ +# Prefer a Gitea-hosted node:20-alpine so docker build does not HEAD registry-1.docker.io. +# Order: local Gitea tag → pull Gitea → local Docker Hub tag → pull Hub (retries) → tag/push Gitea. +param( + [Parameter(Mandatory = $true)][string]$RegistryPrefix, + [string]$OutFile = '.ci-node-image', + [string]$HubImage = 'node:20-alpine' +) + +$ErrorActionPreference = 'Continue' +$mirror = "$RegistryPrefix/node:20-alpine" + +function Test-Image([string]$Name) { + docker image inspect $Name 2>&1 | Out-Null + return ($LASTEXITCODE -eq 0) +} + +function Invoke-Pull([string]$Name, [int]$Attempts) { + for ($i = 1; $i -le $Attempts; $i++) { + Write-Host "docker pull $Name (attempt $i/$Attempts)" + docker pull $Name + if ($LASTEXITCODE -eq 0) { return $true } + if ($i -lt $Attempts) { Start-Sleep -Seconds (10 * $i) } + } + return $false +} + +function Save-Choice([string]$Name) { + $utf8 = New-Object System.Text.UTF8Encoding $false + [System.IO.File]::WriteAllText((Join-Path (Get-Location) $OutFile), $Name + "`n", $utf8) + Write-Host "NODE_IMAGE=$Name" +} + +if (Test-Image $mirror) { + Write-Host "Using local $mirror" + Save-Choice $mirror + exit 0 +} + +if (Invoke-Pull $mirror 2) { + Save-Choice $mirror + exit 0 +} + +if (-not (Test-Image $HubImage)) { + if (-not (Invoke-Pull $HubImage 5)) { + Write-Host "Cannot pull $HubImage from Docker Hub (TLS timeout or blocked)." + Write-Host "On the Windows runner, when Hub is reachable:" + Write-Host " docker pull $HubImage" + Write-Host " docker tag $HubImage $mirror" + Write-Host " docker push $mirror" + Write-Host "Then re-run this workflow." + exit 1 + } +} + +Write-Host "Tagging $HubImage as $mirror" +docker tag $HubImage $mirror +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +docker push $mirror +if ($LASTEXITCODE -ne 0) { + Write-Host "Could not push $mirror — docker build will use the local tag (legacy builder)." + $utf8 = New-Object System.Text.UTF8Encoding $false + [System.IO.File]::WriteAllText((Join-Path (Get-Location) '.ci-use-legacy-builder'), "1`n", $utf8) +} + +Save-Choice $mirror +exit 0