From 663eafa3e85dfc770a7c8f87047038100f7a6b02 Mon Sep 17 00:00:00 2001 From: rameen Date: Sat, 5 Sep 2026 19:03:06 +0330 Subject: [PATCH 1/6] fix: fail Gitea deploy when Docker image push does not succeed Windows PowerShell ignored docker exit codes, so build-and-push looked green while :sha tags were never pushed and deploy pulled missing images. --- .gitea/workflows/prod-tag-deploy.yml | 40 +++++++++----- .gitea/workflows/registry-build-deploy.yml | 62 +++++++++++++--------- infrastructure/STAGING-DEPLOY.md | 1 + 3 files changed, 66 insertions(+), 37 deletions(-) diff --git a/.gitea/workflows/prod-tag-deploy.yml b/.gitea/workflows/prod-tag-deploy.yml index e01704b..4c1e85a 100644 --- a/.gitea/workflows/prod-tag-deploy.yml +++ b/.gitea/workflows/prod-tag-deploy.yml @@ -44,8 +44,6 @@ defaults: jobs: build-and-push: runs-on: windows - outputs: - image_tag: ${{ steps.meta.outputs.image_tag }} steps: - name: Checkout (this Gitea) run: | @@ -75,6 +73,7 @@ jobs: } $env:GIT_TERMINAL_PROMPT = '0' git clone --depth 1 --branch $Branch $cloneUrl . + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Resolve image tag (v*.*.* only) id: meta @@ -90,10 +89,7 @@ jobs: Write-Host "Production images must be tagged vMAJOR.MINOR.PATCH (got: $tag)" exit 1 } - $utf8 = New-Object System.Text.UTF8Encoding $false - [System.IO.File]::AppendAllText($env:GITHUB_OUTPUT, "image_tag=$tag`n", $utf8) $prefix = "${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_OWNER }}" - [System.IO.File]::AppendAllText($env:GITHUB_ENV, "REGISTRY_PREFIX=$prefix`n", $utf8) Write-Host "image_tag=$tag REGISTRY_PREFIX=$prefix" - name: Log in to container registry @@ -103,13 +99,19 @@ jobs: ${{ secrets.REGISTRY_PASSWORD }} '@ $pass.Trim() | docker login "${{ vars.REGISTRY_HOST }}" -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Build and push backend (tag only, not :latest) run: | $ErrorActionPreference = 'Stop' - $tag = "${{ steps.meta.outputs.image_tag }}" - docker build -t "$env:REGISTRY_PREFIX/dyolink-backend:$tag" ./backend - docker push "$env:REGISTRY_PREFIX/dyolink-backend:$tag" + $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 } + docker push "$prefix/dyolink-backend:$tag" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Build and push frontend (nudentic.ir baked in) env: @@ -117,19 +119,24 @@ jobs: NEXT_PUBLIC_SENTRY_DSN: ${{ vars.NEXT_PUBLIC_SENTRY_DSN }} run: | $ErrorActionPreference = 'Stop' - $tag = "${{ steps.meta.outputs.image_tag }}" + $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() $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 "$env:REGISTRY_PREFIX/dyolink-frontend:$tag" ` + -t "$prefix/dyolink-frontend:$tag" ` ./frontend - docker push "$env:REGISTRY_PREFIX/dyolink-frontend:$tag" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + docker push "$prefix/dyolink-frontend:$tag" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } deploy: needs: build-and-push @@ -163,6 +170,7 @@ jobs: } $env:GIT_TERMINAL_PROMPT = '0' git clone --depth 1 --branch $Branch $cloneUrl . + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Write SSH key run: | @@ -193,19 +201,25 @@ jobs: if ([string]::IsNullOrWhiteSpace($infra)) { $infra = '/opt/dyolink/infrastructure' } $ssh = @('-i', $env:PROD_SSH_KEY_PATH, '-o', 'StrictHostKeyChecking=accept-new') ssh.exe @ssh -p $port "${user}@${hostName}" "mkdir -p $infra/scripts $infra/nginx" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } scp.exe @ssh -P $port ` infrastructure/docker-compose.prod.yml ` "${user}@${hostName}:${infra}/docker-compose.prod.yml" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } scp.exe @ssh -P $port ` infrastructure/scripts/prod-remote-deploy.sh ` "${user}@${hostName}:${infra}/scripts/prod-remote-deploy.sh" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } scp.exe @ssh -P $port ` infrastructure/scripts/render-nginx-ssl.sh ` "${user}@${hostName}:${infra}/scripts/render-nginx-ssl.sh" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } scp.exe @ssh -P $port ` infrastructure/nginx/nginx.ssl.conf.template ` "${user}@${hostName}:${infra}/nginx/nginx.ssl.conf.template" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } ssh.exe @ssh -p $port "${user}@${hostName}" "chmod +x $infra/scripts/prod-remote-deploy.sh $infra/scripts/render-nginx-ssl.sh" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Login on Linux and deploy tag run: | @@ -218,7 +232,8 @@ jobs: if ([string]::IsNullOrWhiteSpace($infra)) { $infra = '/opt/dyolink/infrastructure' } $regHost = '${{ vars.PROD_REGISTRY_HOST }}'.Trim() if ([string]::IsNullOrWhiteSpace($regHost)) { $regHost = 'wixur.ir:3000' } - $tag = "${{ needs.build-and-push.outputs.image_tag }}" + $dispatchTag = '${{ github.event.inputs.tag }}'.Trim() + if (-not [string]::IsNullOrWhiteSpace($dispatchTag)) { $tag = $dispatchTag } else { $tag = "${{ github.ref_name }}" } $pass = @' ${{ secrets.REGISTRY_PASSWORD }} '@ @@ -226,3 +241,4 @@ jobs: $ssh = @('-i', $env:PROD_SSH_KEY_PATH, '-o', 'StrictHostKeyChecking=accept-new') $remote = "docker login $regHost -u $regUser --password-stdin && PROD_INFRA_DIR=$infra $infra/scripts/prod-remote-deploy.sh $tag" $pass.Trim() | ssh.exe @ssh -p $port "${user}@${hostName}" $remote + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/.gitea/workflows/registry-build-deploy.yml b/.gitea/workflows/registry-build-deploy.yml index cfd05cd..29f12de 100644 --- a/.gitea/workflows/registry-build-deploy.yml +++ b/.gitea/workflows/registry-build-deploy.yml @@ -33,6 +33,7 @@ # Docker on runner: insecure-registries e.g. ["host.docker.internal:3000","wixur.ir:3000"] # # Runner: self-hosted with Docker + git. Default shell is powershell (Windows act_runner). +# Windows PowerShell 5.1 does not fail a step when docker/git return non-zero — always check $LASTEXITCODE. name: Registry — build, push, deploy @@ -48,8 +49,6 @@ defaults: jobs: build-and-push: runs-on: windows - outputs: - image_tag: ${{ steps.meta.outputs.image_tag }} steps: - name: Checkout (clone from this Gitea — no gitea.com) run: | @@ -74,16 +73,7 @@ jobs: } $env:GIT_TERMINAL_PROMPT = '0' git clone --depth 1 --branch $Branch $cloneUrl . - - - name: Image tag and registry prefix - id: meta - run: | - $ErrorActionPreference = 'Stop' - $short = (git rev-parse --short HEAD).Trim() - $utf8 = New-Object System.Text.UTF8Encoding $false - [System.IO.File]::AppendAllText($env:GITHUB_OUTPUT, "image_tag=$short`n", $utf8) - $prefix = "${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_OWNER }}" - [System.IO.File]::AppendAllText($env:GITHUB_ENV, "REGISTRY_PREFIX=$prefix`n", $utf8) + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Log in to container registry run: | @@ -92,17 +82,27 @@ jobs: ${{ secrets.REGISTRY_PASSWORD }} '@ $pass.Trim() | docker login "${{ vars.REGISTRY_HOST }}" -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Build and push backend run: | $ErrorActionPreference = 'Stop' - $tag = "${{ steps.meta.outputs.image_tag }}" + $tag = "${{ github.sha }}".Substring(0, 7) + $prefix = "${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_OWNER }}".Trim() + if ([string]::IsNullOrWhiteSpace($tag) -or [string]::IsNullOrWhiteSpace($prefix)) { + Write-Host "Missing github.sha, REGISTRY_HOST, or REGISTRY_OWNER" + exit 1 + } + Write-Host "Building $prefix/dyolink-backend:$tag" docker build ` - -t "$env:REGISTRY_PREFIX/dyolink-backend:$tag" ` - -t "$env:REGISTRY_PREFIX/dyolink-backend:latest" ` + -t "$prefix/dyolink-backend:$tag" ` + -t "$prefix/dyolink-backend:latest" ` ./backend - docker push "$env:REGISTRY_PREFIX/dyolink-backend:$tag" - docker push "$env:REGISTRY_PREFIX/dyolink-backend:latest" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + docker push "$prefix/dyolink-backend:$tag" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + docker push "$prefix/dyolink-backend:latest" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Build and push frontend env: @@ -110,19 +110,24 @@ jobs: NEXT_PUBLIC_SENTRY_DSN: ${{ vars.NEXT_PUBLIC_SENTRY_DSN }} run: | $ErrorActionPreference = 'Stop' - $tag = "${{ steps.meta.outputs.image_tag }}" + $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 "$env:REGISTRY_PREFIX/dyolink-frontend:$tag" ` - -t "$env:REGISTRY_PREFIX/dyolink-frontend:latest" ` + -t "$prefix/dyolink-frontend:$tag" ` + -t "$prefix/dyolink-frontend:latest" ` ./frontend - docker push "$env:REGISTRY_PREFIX/dyolink-frontend:$tag" - docker push "$env:REGISTRY_PREFIX/dyolink-frontend:latest" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + docker push "$prefix/dyolink-frontend:$tag" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + docker push "$prefix/dyolink-frontend:latest" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } deploy: needs: build-and-push @@ -151,6 +156,7 @@ jobs: } $env:GIT_TERMINAL_PROMPT = '0' git clone --depth 1 --branch $Branch $cloneUrl . + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Write deploy.registry.env and validate secrets path run: | @@ -173,16 +179,19 @@ jobs: if ([string]::IsNullOrWhiteSpace($stagingPort)) { $stagingPort = '80' } $localPort = '${{ vars.STAGING_LOCAL_PORT }}'.Trim() if ([string]::IsNullOrWhiteSpace($localPort)) { $localPort = '18088' } - $imageTag = "${{ needs.build-and-push.outputs.image_tag }}" + $imageTag = "${{ github.sha }}".Substring(0, 7) + $prefix = "${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_OWNER }}".Trim() + Write-Host "IMAGE_TAG=$imageTag REGISTRY_PREFIX=$prefix" $lines = @( - "REGISTRY_PREFIX=${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_OWNER }}", + "REGISTRY_PREFIX=$prefix", "IMAGE_TAG=$imageTag", "STAGING_HTTP_PORT=$stagingPort", "STAGING_LOCAL_PORT=$localPort", "DEPLOY_SECRETS_DIR=$SD" ) Set-Location infrastructure - $lines | Set-Content -Path deploy.registry.env -Encoding utf8 + $utf8 = New-Object System.Text.UTF8Encoding $false + [System.IO.File]::WriteAllText((Join-Path (Get-Location) 'deploy.registry.env'), ($lines -join "`n") + "`n", $utf8) - name: Log in to container registry (for pull) run: | @@ -191,10 +200,13 @@ jobs: ${{ secrets.REGISTRY_PASSWORD }} '@ $pass.Trim() | docker login "${{ vars.REGISTRY_HOST }}" -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Pull and start stack run: | $ErrorActionPreference = 'Stop' Set-Location infrastructure docker compose -f docker-compose.registry.yml --env-file deploy.registry.env pull backend frontend + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } docker compose -f docker-compose.registry.yml --env-file deploy.registry.env up -d + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/infrastructure/STAGING-DEPLOY.md b/infrastructure/STAGING-DEPLOY.md index 8dcecac..c20eb8a 100644 --- a/infrastructure/STAGING-DEPLOY.md +++ b/infrastructure/STAGING-DEPLOY.md @@ -300,6 +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. | | `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 | -- 2.53.0.windows.1 From c1846f8e22d6a30c14a5dc4b0505d89e15ce4998 Mon Sep 17 00:00:00 2001 From: rameen Date: Sat, 5 Sep 2026 19:12:37 +0330 Subject: [PATCH 2/6] fix: mirror node:20-alpine to Gitea when Docker Hub TLS times out BuildKit was failing on registry-1.docker.io even when a local base image existed. CI now prefers a Gitea-hosted NODE_IMAGE and retries docker build. --- .gitea/workflows/prod-tag-deploy.yml | 58 ++++++++++++---- .gitea/workflows/registry-build-deploy.yml | 67 +++++++++++++----- backend/Dockerfile | 7 +- frontend/Dockerfile | 7 +- infrastructure/STAGING-DEPLOY.md | 3 +- .../scripts/ci-resolve-node-image.ps1 | 68 +++++++++++++++++++ 6 files changed, 175 insertions(+), 35 deletions(-) create mode 100644 infrastructure/scripts/ci-resolve-node-image.ps1 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 -- 2.53.0.windows.1 From 005e0b0394e8756bf79050b301733061a949f8a5 Mon Sep 17 00:00:00 2001 From: rameen Date: Sat, 5 Sep 2026 19:48:46 +0330 Subject: [PATCH 3/6] fix: parse ci-resolve-node-image.ps1 on Windows PowerShell 5.1 --- infrastructure/scripts/ci-resolve-node-image.ps1 | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/infrastructure/scripts/ci-resolve-node-image.ps1 b/infrastructure/scripts/ci-resolve-node-image.ps1 index 80d9aa3..e62d5de 100644 --- a/infrastructure/scripts/ci-resolve-node-image.ps1 +++ b/infrastructure/scripts/ci-resolve-node-image.ps1 @@ -1,5 +1,6 @@ # 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. +# Order: local Gitea tag -> pull Gitea -> local Docker Hub tag -> pull Hub (retries) -> tag/push Gitea. +# ASCII only: Windows PowerShell 5.1 + act_runner mis-parses backtick escapes in this file. param( [Parameter(Mandatory = $true)][string]$RegistryPrefix, [string]$OutFile = '.ci-node-image', @@ -8,6 +9,7 @@ param( $ErrorActionPreference = 'Continue' $mirror = "$RegistryPrefix/node:20-alpine" +$nl = [char]10 function Test-Image([string]$Name) { docker image inspect $Name 2>&1 | Out-Null @@ -26,7 +28,8 @@ function Invoke-Pull([string]$Name, [int]$Attempts) { function Save-Choice([string]$Name) { $utf8 = New-Object System.Text.UTF8Encoding $false - [System.IO.File]::WriteAllText((Join-Path (Get-Location) $OutFile), $Name + "`n", $utf8) + $path = Join-Path (Get-Location) $OutFile + [System.IO.File]::WriteAllText($path, ($Name + $nl), $utf8) Write-Host "NODE_IMAGE=$Name" } @@ -59,9 +62,9 @@ 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) + Write-Host "Could not push $mirror - docker build will use the local tag (legacy builder)." + $flag = Join-Path (Get-Location) '.ci-use-legacy-builder' + New-Item -ItemType File -Path $flag -Force | Out-Null } Save-Choice $mirror -- 2.53.0.windows.1 From 8fa856a98092245954ecd2816e8de64209fdd4c1 Mon Sep 17 00:00:00 2001 From: rameen Date: Sat, 5 Sep 2026 19:59:45 +0330 Subject: [PATCH 4/6] fix: pull node:20-alpine from Arvan/ECR when Docker Hub is blocked --- .gitea/workflows/prod-tag-deploy.yml | 6 +- .gitea/workflows/registry-build-deploy.yml | 8 +- infrastructure/STAGING-DEPLOY.md | 2 +- .../scripts/ci-resolve-node-image.ps1 | 76 +++++++++++++------ 4 files changed, 63 insertions(+), 29 deletions(-) diff --git a/.gitea/workflows/prod-tag-deploy.yml b/.gitea/workflows/prod-tag-deploy.yml index 1dab77d..5b0a80e 100644 --- a/.gitea/workflows/prod-tag-deploy.yml +++ b/.gitea/workflows/prod-tag-deploy.yml @@ -101,11 +101,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) + - name: Resolve node:20-alpine (Gitea, then mirrors, Hub last) 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 + $extra = '${{ vars.NODE_IMAGE_SOURCE }}'.Trim() + if ($extra -like '*NODE_IMAGE_SOURCE*') { $extra = '' } + powershell -NoProfile -ExecutionPolicy Bypass -File .\infrastructure\scripts\ci-resolve-node-image.ps1 -RegistryPrefix $prefix -ExtraSources $extra if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Build and push backend (tag only, not :latest) diff --git a/.gitea/workflows/registry-build-deploy.yml b/.gitea/workflows/registry-build-deploy.yml index d00c527..aa9066e 100644 --- a/.gitea/workflows/registry-build-deploy.yml +++ b/.gitea/workflows/registry-build-deploy.yml @@ -17,6 +17,8 @@ # STAGING_HTTP_PORT public HTTP port (default 80) — Windows portproxy listens here → 18088 # STAGING_LOCAL_PORT Docker bind on 127.0.0.1 (default 18088) — must not equal the public port if portproxy owns it # CLONE_HOST git clone host when runner = Gitea host → 127.0.0.1:3000 +# NODE_IMAGE_SOURCE extra base-image ref(s), comma-separated, tried before built-in mirrors +# e.g. docker.arvancloud.ir/library/node:20-alpine # # Same Windows PC runs Gitea + runner + deploy: # CLONE_HOST → 127.0.0.1:3000 (git runs on Windows host) @@ -84,11 +86,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) + - name: Resolve node:20-alpine (Gitea, then mirrors, Hub last) 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 + $extra = '${{ vars.NODE_IMAGE_SOURCE }}'.Trim() + if ($extra -like '*NODE_IMAGE_SOURCE*') { $extra = '' } + powershell -NoProfile -ExecutionPolicy Bypass -File .\infrastructure\scripts\ci-resolve-node-image.ps1 -RegistryPrefix $prefix -ExtraSources $extra if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Build and push backend diff --git a/infrastructure/STAGING-DEPLOY.md b/infrastructure/STAGING-DEPLOY.md index cd378d9..9eeb47d 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). | -| `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`. | +| `TLS handshake timeout` to `registry-1.docker.io` / `node:20-alpine` | Docker Hub is blocked or slow from the Windows runner. CI pulls `node:20-alpine` from **Arvan / ECR Public / GCR**, then pushes `/node:20-alpine` to Gitea (later builds skip Hub). Optional variable `NODE_IMAGE_SOURCE` (comma-separated image refs). One-time on the runner: `docker pull docker.arvancloud.ir/library/node:20-alpine` then tag/push to Gitea. | | `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 | diff --git a/infrastructure/scripts/ci-resolve-node-image.ps1 b/infrastructure/scripts/ci-resolve-node-image.ps1 index e62d5de..901f805 100644 --- a/infrastructure/scripts/ci-resolve-node-image.ps1 +++ b/infrastructure/scripts/ci-resolve-node-image.ps1 @@ -1,10 +1,11 @@ # 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. +# Order: Gitea -> optional NODE_IMAGE_SOURCE -> regional/official mirrors -> Docker Hub last. # ASCII only: Windows PowerShell 5.1 + act_runner mis-parses backtick escapes in this file. param( [Parameter(Mandatory = $true)][string]$RegistryPrefix, [string]$OutFile = '.ci-node-image', - [string]$HubImage = 'node:20-alpine' + [string]$HubImage = 'node:20-alpine', + [string]$ExtraSources = '' ) $ErrorActionPreference = 'Continue' @@ -21,7 +22,7 @@ function Invoke-Pull([string]$Name, [int]$Attempts) { 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) } + if ($i -lt $Attempts) { Start-Sleep -Seconds 5 } } return $false } @@ -33,39 +34,66 @@ function Save-Choice([string]$Name) { Write-Host "NODE_IMAGE=$Name" } +function Publish-Mirror([string]$Src) { + Write-Host "Tagging $Src as $mirror" + docker tag $Src $mirror + if ($LASTEXITCODE -ne 0) { return $false } + docker push $mirror + if ($LASTEXITCODE -ne 0) { + Write-Host "Could not push $mirror - docker build will use the local tag (legacy builder)." + $flag = Join-Path (Get-Location) '.ci-use-legacy-builder' + New-Item -ItemType File -Path $flag -Force | Out-Null + } + Save-Choice $mirror + return $true +} + if (Test-Image $mirror) { Write-Host "Using local $mirror" Save-Choice $mirror exit 0 } -if (Invoke-Pull $mirror 2) { +if (Invoke-Pull $mirror 1) { 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 +$sources = New-Object System.Collections.ArrayList +if (-not [string]::IsNullOrWhiteSpace($ExtraSources)) { + foreach ($part in ($ExtraSources -split ',')) { + $src = $part.Trim() + if ($src.Length -gt 0) { [void]$sources.Add($src) } + } +} +# Iran-reachable proxy of Docker Hub official images, then public official mirrors, Hub last. +foreach ($src in @( + 'docker.arvancloud.ir/library/node:20-alpine', + 'public.ecr.aws/docker/library/node:20-alpine', + 'mirror.gcr.io/library/node:20-alpine', + $HubImage + )) { + if (-not $sources.Contains($src)) { [void]$sources.Add($src) } +} + +foreach ($src in $sources) { + if (Test-Image $src) { + Write-Host "Found local $src" + if (Publish-Mirror $src) { exit 0 } } } -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)." - $flag = Join-Path (Get-Location) '.ci-use-legacy-builder' - New-Item -ItemType File -Path $flag -Force | Out-Null +foreach ($src in $sources) { + if (Invoke-Pull $src 2) { + if (Publish-Mirror $src) { exit 0 } + } } -Save-Choice $mirror -exit 0 +Write-Host "Could not pull node:20-alpine from Gitea, mirrors, or Docker Hub." +Write-Host "Set repository variable NODE_IMAGE_SOURCE to a reachable image, for example:" +Write-Host " docker.arvancloud.ir/library/node:20-alpine" +Write-Host "Or on the Windows runner:" +Write-Host " docker pull docker.arvancloud.ir/library/node:20-alpine" +Write-Host " docker tag docker.arvancloud.ir/library/node:20-alpine $mirror" +Write-Host " docker push $mirror" +exit 1 -- 2.53.0.windows.1 From feafc3a2198efd1bd1ba385ea36f7e13bae32c5e Mon Sep 17 00:00:00 2001 From: rameen Date: Sat, 5 Sep 2026 20:11:18 +0330 Subject: [PATCH 5/6] fix: pass empty ExtraSources when NODE_IMAGE_SOURCE is unset --- .gitea/workflows/prod-tag-deploy.yml | 2 +- .gitea/workflows/registry-build-deploy.yml | 2 +- infrastructure/scripts/ci-resolve-node-image.ps1 | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/prod-tag-deploy.yml b/.gitea/workflows/prod-tag-deploy.yml index 5b0a80e..b74a988 100644 --- a/.gitea/workflows/prod-tag-deploy.yml +++ b/.gitea/workflows/prod-tag-deploy.yml @@ -107,7 +107,7 @@ jobs: $prefix = "${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_OWNER }}".Trim() $extra = '${{ vars.NODE_IMAGE_SOURCE }}'.Trim() if ($extra -like '*NODE_IMAGE_SOURCE*') { $extra = '' } - powershell -NoProfile -ExecutionPolicy Bypass -File .\infrastructure\scripts\ci-resolve-node-image.ps1 -RegistryPrefix $prefix -ExtraSources $extra + powershell -NoProfile -ExecutionPolicy Bypass -File .\infrastructure\scripts\ci-resolve-node-image.ps1 -RegistryPrefix "$prefix" -ExtraSources "$extra" if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Build and push backend (tag only, not :latest) diff --git a/.gitea/workflows/registry-build-deploy.yml b/.gitea/workflows/registry-build-deploy.yml index aa9066e..5372837 100644 --- a/.gitea/workflows/registry-build-deploy.yml +++ b/.gitea/workflows/registry-build-deploy.yml @@ -92,7 +92,7 @@ jobs: $prefix = "${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_OWNER }}".Trim() $extra = '${{ vars.NODE_IMAGE_SOURCE }}'.Trim() if ($extra -like '*NODE_IMAGE_SOURCE*') { $extra = '' } - powershell -NoProfile -ExecutionPolicy Bypass -File .\infrastructure\scripts\ci-resolve-node-image.ps1 -RegistryPrefix $prefix -ExtraSources $extra + powershell -NoProfile -ExecutionPolicy Bypass -File .\infrastructure\scripts\ci-resolve-node-image.ps1 -RegistryPrefix "$prefix" -ExtraSources "$extra" if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Build and push backend diff --git a/infrastructure/scripts/ci-resolve-node-image.ps1 b/infrastructure/scripts/ci-resolve-node-image.ps1 index 901f805..64b836f 100644 --- a/infrastructure/scripts/ci-resolve-node-image.ps1 +++ b/infrastructure/scripts/ci-resolve-node-image.ps1 @@ -5,6 +5,7 @@ param( [Parameter(Mandatory = $true)][string]$RegistryPrefix, [string]$OutFile = '.ci-node-image', [string]$HubImage = 'node:20-alpine', + [AllowEmptyString()] [string]$ExtraSources = '' ) -- 2.53.0.windows.1 From 6c7e0574b9d3674936226946a9012d2e2b762ec4 Mon Sep 17 00:00:00 2001 From: rameen Date: Sat, 5 Sep 2026 20:18:06 +0330 Subject: [PATCH 6/6] fix: omit ExtraSources when NODE_IMAGE_SOURCE is empty --- .gitea/workflows/prod-tag-deploy.yml | 5 ++++- .gitea/workflows/registry-build-deploy.yml | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/prod-tag-deploy.yml b/.gitea/workflows/prod-tag-deploy.yml index b74a988..d2126ca 100644 --- a/.gitea/workflows/prod-tag-deploy.yml +++ b/.gitea/workflows/prod-tag-deploy.yml @@ -107,7 +107,10 @@ jobs: $prefix = "${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_OWNER }}".Trim() $extra = '${{ vars.NODE_IMAGE_SOURCE }}'.Trim() if ($extra -like '*NODE_IMAGE_SOURCE*') { $extra = '' } - powershell -NoProfile -ExecutionPolicy Bypass -File .\infrastructure\scripts\ci-resolve-node-image.ps1 -RegistryPrefix "$prefix" -ExtraSources "$extra" + # Do not nest powershell -File: empty -ExtraSources "$extra" is dropped and PS5.1 errors MissingArgument. + $scriptArgs = @{ RegistryPrefix = $prefix } + if (-not [string]::IsNullOrWhiteSpace($extra)) { $scriptArgs['ExtraSources'] = $extra } + & .\infrastructure\scripts\ci-resolve-node-image.ps1 @scriptArgs if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Build and push backend (tag only, not :latest) diff --git a/.gitea/workflows/registry-build-deploy.yml b/.gitea/workflows/registry-build-deploy.yml index 5372837..cabfab8 100644 --- a/.gitea/workflows/registry-build-deploy.yml +++ b/.gitea/workflows/registry-build-deploy.yml @@ -92,7 +92,10 @@ jobs: $prefix = "${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_OWNER }}".Trim() $extra = '${{ vars.NODE_IMAGE_SOURCE }}'.Trim() if ($extra -like '*NODE_IMAGE_SOURCE*') { $extra = '' } - powershell -NoProfile -ExecutionPolicy Bypass -File .\infrastructure\scripts\ci-resolve-node-image.ps1 -RegistryPrefix "$prefix" -ExtraSources "$extra" + # Do not nest powershell -File: empty -ExtraSources "$extra" is dropped and PS5.1 errors MissingArgument. + $scriptArgs = @{ RegistryPrefix = $prefix } + if (-not [string]::IsNullOrWhiteSpace($extra)) { $scriptArgs['ExtraSources'] = $extra } + & .\infrastructure\scripts\ci-resolve-node-image.ps1 @scriptArgs if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Build and push backend -- 2.53.0.windows.1