name: Build & Push on: push: branches: [fix_branch] workflow_dispatch: concurrency: group: build-${{ gitea.ref }} cancel-in-progress: false env: NODE_VERSION: "20" # TODO: Remove after installing internal CA certificate on the runner GIT_SSL_NO_VERIFY: "true" REGISTRY_HOST: 192.168.3.80 DEPLOY_REGISTRY_HOST: 10.0.0.4 DEPLOY_SSH_HOST: 10.0.0.1 DOCKERFILE_PATH: Dockerfile.production DOCKER_PLATFORM: linux/amd64 DEPLOY_ROOT: /opt/rentaldrivego jobs: build-image: name: Build & Push Docker Image runs-on: ubuntu-latest outputs: image_repository: ${{ steps.image-meta.outputs.repository }} docker_image: ${{ steps.image-meta.outputs.full }} steps: - name: Checkout repository shell: bash env: GITEA_SERVER_URL: ${{ gitea.server_url }} GITEA_REPOSITORY: ${{ gitea.repository }} GITEA_SHA: ${{ gitea.sha }} CHECKOUT_TOKEN: ${{ gitea.token }} run: | set -euo pipefail if ! command -v git >/dev/null 2>&1; then echo "::error::git must be available in the runner image; this workflow cannot install git while runner DNS is unavailable." exit 1 fi WORKSPACE="${GITHUB_WORKSPACE:-$PWD}" mkdir -p "$WORKSPACE" cd "$WORKSPACE" SERVER_URL="${GITEA_SERVER_URL:-${GITHUB_SERVER_URL:-}}" REPOSITORY="${GITEA_REPOSITORY:-${GITHUB_REPOSITORY:-}}" SHA="${GITEA_SHA:-${GITHUB_SHA:-}}" if [ -z "$SERVER_URL" ] || [ -z "$REPOSITORY" ] || [ -z "$SHA" ]; then echo "::error::Missing repository checkout context" exit 1 fi REPOSITORY_URL="${SERVER_URL}/${REPOSITORY}.git" if [ ! -d .git ]; then git init git remote add origin "$REPOSITORY_URL" fi git config --global --add safe.directory "$WORKSPACE" if [ -n "${CHECKOUT_TOKEN:-}" ]; then git -c "http.extraHeader=Authorization: token ${CHECKOUT_TOKEN}" fetch --no-tags --depth=1 origin "$SHA" else git fetch --no-tags --depth=1 origin "$SHA" fi git checkout --force FETCH_HEAD - name: Docker image metadata id: image-meta run: | REPO="${{ gitea.repository }}" TAG="${{ gitea.sha }}" echo "repository=${REPO}" >> "$GITHUB_OUTPUT" echo "full=${DEPLOY_REGISTRY_HOST}/${REPO}:${TAG}" >> "$GITHUB_OUTPUT" echo "latest=${DEPLOY_REGISTRY_HOST}/${REPO}:latest" >> "$GITHUB_OUTPUT" - name: Check Docker registry credentials id: registry-check run: | if [ -n "${{ secrets.REGISTRY_USERNAME }}" ] && [ -n "${{ secrets.REGISTRY_PASSWORD }}" ]; then echo "available=true" >> "$GITHUB_OUTPUT" else echo "::warning::REGISTRY_USERNAME or REGISTRY_PASSWORD secrets not set — push will be skipped" echo "available=false" >> "$GITHUB_OUTPUT" fi - name: Validate public URLs shell: bash env: NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }} NEXT_PUBLIC_HOMEPAGE_URL: ${{ secrets.NEXT_PUBLIC_HOMEPAGE_URL }} NEXT_PUBLIC_CARPLACE_URL: ${{ secrets.NEXT_PUBLIC_CARPLACE_URL }} NEXT_PUBLIC_STOREFRONT_URL: ${{ secrets.NEXT_PUBLIC_STOREFRONT_URL }} NEXT_PUBLIC_DASHBOARD_URL: ${{ secrets.NEXT_PUBLIC_DASHBOARD_URL }} NEXT_PUBLIC_ADMIN_URL: ${{ secrets.NEXT_PUBLIC_ADMIN_URL }} SITE_ORIGIN: ${{ secrets.SITE_ORIGIN }} run: | validate_url() { name="$1" value="$2" if [ -z "$value" ]; then echo "::error::$name is missing — add it to Gitea Actions secrets" exit 1 fi case "$value" in http://*|https://*) ;; *) echo "::error::$name must be an absolute URL (got: $value)" exit 1 ;; esac } if [ -z "$NEXT_PUBLIC_CARPLACE_URL" ] && [ -n "$NEXT_PUBLIC_STOREFRONT_URL" ]; then NEXT_PUBLIC_CARPLACE_URL="${NEXT_PUBLIC_STOREFRONT_URL%/}/carplace" fi validate_url NEXT_PUBLIC_API_URL "$NEXT_PUBLIC_API_URL" validate_url NEXT_PUBLIC_HOMEPAGE_URL "$NEXT_PUBLIC_HOMEPAGE_URL" validate_url NEXT_PUBLIC_CARPLACE_URL "$NEXT_PUBLIC_CARPLACE_URL" validate_url NEXT_PUBLIC_DASHBOARD_URL "$NEXT_PUBLIC_DASHBOARD_URL" validate_url NEXT_PUBLIC_ADMIN_URL "$NEXT_PUBLIC_ADMIN_URL" validate_url SITE_ORIGIN "$SITE_ORIGIN" - name: Check remote build credentials id: check-build-host env: VPS_HOST: ${{ secrets.VPS_IP }} VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }} VPS_SSH_KEY_B64: ${{ secrets.VPS_SSH_KEY_B64 }} run: | VPS_HOST="${VPS_HOST:-$DEPLOY_SSH_HOST}" if [ -z "$VPS_SSH_KEY" ] && [ -z "$VPS_SSH_KEY_B64" ]; then echo "::error::VPS_SSH_KEY_B64 or VPS_SSH_KEY is required to build on the remote Docker host" exit 1 fi if [ -z "$VPS_HOST" ] || \ [ -z "${{ secrets.VPS_USER }}" ]; then echo "::error::VPS_USER and either VPS_IP or DEPLOY_SSH_HOST are required to build on the remote Docker host" exit 1 fi - name: Check SSH tools run: | if ! command -v ssh >/dev/null 2>&1 || ! command -v ssh-keygen >/dev/null 2>&1 || ! command -v tar >/dev/null 2>&1; then echo "::error::ssh, ssh-keygen, and tar must be available in the runner image; this workflow cannot install packages while runner DNS is unavailable." exit 1 fi - name: Set up SSH key env: VPS_HOST: ${{ secrets.VPS_IP }} VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }} VPS_SSH_KEY_B64: ${{ secrets.VPS_SSH_KEY_B64 }} run: | VPS_HOST="${VPS_HOST:-$DEPLOY_SSH_HOST}" if [ -z "$VPS_HOST" ]; then echo "::error::VPS_IP secret or DEPLOY_SSH_HOST is required" exit 1 fi echo "Using SSH host $VPS_HOST" mkdir -p ~/.ssh && chmod 700 ~/.ssh if [ -n "$VPS_SSH_KEY_B64" ]; then if ! printf '%s' "$VPS_SSH_KEY_B64" | tr -d '[:space:]' | base64 -d > ~/.ssh/id_rsa 2>/tmp/vps_ssh_key_decode.err; then if [ -n "$VPS_SSH_KEY" ]; then echo "::warning::VPS_SSH_KEY_B64 is not valid base64; using VPS_SSH_KEY instead" printf '%b\n' "$VPS_SSH_KEY" | tr -d '\r' > ~/.ssh/id_rsa else echo "::error::VPS_SSH_KEY_B64 is not valid base64. Store a base64-encoded private key there, or set VPS_SSH_KEY to the raw private key." exit 1 fi fi else printf '%b\n' "$VPS_SSH_KEY" | tr -d '\r' > ~/.ssh/id_rsa fi chmod 600 ~/.ssh/id_rsa key_header="$(head -n 1 ~/.ssh/id_rsa || true)" key_size="$(wc -c < ~/.ssh/id_rsa | tr -d ' ')" case "$key_header" in "-----BEGIN "*PRIVATE*" KEY-----") ;; ssh-*|"ecdsa-"*|"sk-"*) echo "::error::Decoded SSH key looks like a public key, not a private key. Encode the private key file, not the .pub file." exit 1 ;; *) echo "::error::Decoded SSH key does not start with a private key header. Decoded byte count: $key_size." exit 1 ;; esac if ! keygen_error="$(ssh-keygen -y -f ~/.ssh/id_rsa 2>&1 >/dev/null)"; then echo "::error::SSH private key is not readable by ssh-keygen: $keygen_error. Use an unencrypted private key or configure a CI-specific deploy key." exit 1 fi ssh-keygen -y -f ~/.ssh/id_rsa > ~/.ssh/id_rsa.pub key_fingerprint="$(ssh-keygen -lf ~/.ssh/id_rsa.pub | awk '{print $2}')" echo "Loaded deploy key fingerprint: $key_fingerprint" echo "Deploy public key: $(cat ~/.ssh/id_rsa.pub)" touch ~/.ssh/known_hosts ssh-keyscan -H "$VPS_HOST" >> ~/.ssh/known_hosts 2>/dev/null || true chmod 644 ~/.ssh/known_hosts - name: Test SSH authentication env: VPS_HOST: ${{ secrets.VPS_IP }} run: | VPS_HOST="${VPS_HOST:-$DEPLOY_SSH_HOST}" if [ -z "$VPS_HOST" ]; then echo "::error::VPS_IP secret or DEPLOY_SSH_HOST is required" exit 1 fi SSH_OPTIONS="-i $HOME/.ssh/id_rsa -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=$HOME/.ssh/known_hosts" key_fingerprint="$(ssh-keygen -lf "$HOME/.ssh/id_rsa.pub" | awk '{print $2}')" echo "Testing SSH authentication to $VPS_HOST with deploy key fingerprint $key_fingerprint" if ! ssh $SSH_OPTIONS "${{ secrets.VPS_USER }}@$VPS_HOST" "printf 'ssh authenticated as '; whoami"; then echo "::error::SSH authentication failed. Verify VPS_USER matches the account that has deploy key fingerprint $key_fingerprint in ~/.ssh/authorized_keys on $VPS_HOST." exit 1 fi - name: Sync build context to VPS env: REMOTE_BUILD_DIR: ${{ env.DEPLOY_ROOT }}/build-context VPS_HOST: ${{ secrets.VPS_IP }} run: | VPS_HOST="${VPS_HOST:-$DEPLOY_SSH_HOST}" if [ -z "$VPS_HOST" ]; then echo "::error::VPS_IP secret or DEPLOY_SSH_HOST is required" exit 1 fi SSH_OPTIONS="-i $HOME/.ssh/id_rsa -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=$HOME/.ssh/known_hosts" ssh $SSH_OPTIONS "${{ secrets.VPS_USER }}@$VPS_HOST" \ "rm -rf '$REMOTE_BUILD_DIR' && mkdir -p '$REMOTE_BUILD_DIR'" tar \ --exclude='.git' \ --exclude='node_modules' \ --exclude='.next' \ --exclude='dist' \ --exclude='coverage' \ -czf - . | ssh $SSH_OPTIONS "${{ secrets.VPS_USER }}@$VPS_HOST" \ "tar -xzf - -C '$REMOTE_BUILD_DIR'" - name: Build and push on VPS env: PUSH_IMAGE: ${{ steps.registry-check.outputs.available == 'true' }} IMAGE_FULL: ${{ steps.image-meta.outputs.full }} IMAGE_LATEST: ${{ steps.image-meta.outputs.latest }} REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }} NEXT_PUBLIC_HOMEPAGE_URL: ${{ secrets.NEXT_PUBLIC_HOMEPAGE_URL }} NEXT_PUBLIC_CARPLACE_URL: ${{ secrets.NEXT_PUBLIC_CARPLACE_URL }} NEXT_PUBLIC_STOREFRONT_URL: ${{ secrets.NEXT_PUBLIC_STOREFRONT_URL }} NEXT_PUBLIC_DASHBOARD_URL: ${{ secrets.NEXT_PUBLIC_DASHBOARD_URL }} NEXT_PUBLIC_ADMIN_URL: ${{ secrets.NEXT_PUBLIC_ADMIN_URL }} SITE_ORIGIN: ${{ secrets.SITE_ORIGIN }} REMOTE_BUILD_DIR: ${{ env.DEPLOY_ROOT }}/build-context VPS_HOST: ${{ secrets.VPS_IP }} run: | VPS_HOST="${VPS_HOST:-$DEPLOY_SSH_HOST}" if [ -z "$VPS_HOST" ]; then echo "::error::VPS_IP secret or DEPLOY_SSH_HOST is required" exit 1 fi SSH_OPTIONS="-i $HOME/.ssh/id_rsa -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=$HOME/.ssh/known_hosts" REGISTRY_PASSWORD_B64="$(printf '%s' "$REGISTRY_PASSWORD" | base64 | tr -d '\n')" if [ -z "$NEXT_PUBLIC_CARPLACE_URL" ] && [ -n "$NEXT_PUBLIC_STOREFRONT_URL" ]; then NEXT_PUBLIC_CARPLACE_URL="${NEXT_PUBLIC_STOREFRONT_URL%/}/carplace" fi ssh $SSH_OPTIONS "${{ secrets.VPS_USER }}@$VPS_HOST" " set -e cd '$REMOTE_BUILD_DIR' if ! command -v docker >/dev/null 2>&1; then echo 'Docker must be installed on the VPS build host' >&2 exit 1 fi if [ '$PUSH_IMAGE' = 'true' ]; then printf '%s' '$REGISTRY_PASSWORD_B64' | base64 -d | docker login '$DEPLOY_REGISTRY_HOST' \ --username '$REGISTRY_USERNAME' \ --password-stdin fi docker build \ --file '$DOCKERFILE_PATH' \ --platform '$DOCKER_PLATFORM' \ --tag '$IMAGE_FULL' \ --tag '$IMAGE_LATEST' \ --build-arg API_INTERNAL_URL=http://api:4000/api/v1 \ --build-arg DASHBOARD_INTERNAL_URL=http://dashboard:3001 \ --build-arg ADMIN_INTERNAL_URL=http://admin:3002 \ --build-arg NEXT_PUBLIC_API_URL='$NEXT_PUBLIC_API_URL' \ --build-arg NEXT_PUBLIC_HOMEPAGE_URL='$NEXT_PUBLIC_HOMEPAGE_URL' \ --build-arg NEXT_PUBLIC_CARPLACE_URL='$NEXT_PUBLIC_CARPLACE_URL' \ --build-arg NEXT_PUBLIC_DASHBOARD_URL='$NEXT_PUBLIC_DASHBOARD_URL' \ --build-arg NEXT_PUBLIC_ADMIN_URL='$NEXT_PUBLIC_ADMIN_URL' \ --build-arg SITE_ORIGIN='$SITE_ORIGIN' \ . if [ '$PUSH_IMAGE' = 'true' ]; then docker push '$IMAGE_FULL' docker push '$IMAGE_LATEST' fi " - name: Deploy production stack on VPS env: IMAGE_REPOSITORY: ${{ steps.image-meta.outputs.repository }} IMAGE_TAG: ${{ gitea.sha }} ENV_DOCKER_PRODUCTION: ${{ secrets.ENV_DOCKER_PRODUCTION }} ENV_DOCKER_PRODUCTION_B64: ${{ secrets.ENV_DOCKER_PRODUCTION_B64 }} REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} REMOTE_BUILD_DIR: ${{ env.DEPLOY_ROOT }}/build-context VPS_HOST: ${{ secrets.VPS_IP }} run: | VPS_HOST="${VPS_HOST:-$DEPLOY_SSH_HOST}" if [ -z "$VPS_HOST" ]; then echo "::error::VPS_IP secret or DEPLOY_SSH_HOST is required" exit 1 fi SSH_OPTIONS="-i $HOME/.ssh/id_rsa -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=$HOME/.ssh/known_hosts" ENV_DOCKER_PRODUCTION_B64_CLEAN="$(printf '%s' "$ENV_DOCKER_PRODUCTION_B64" | tr -d '[:space:]')" ENV_DOCKER_PRODUCTION_RAW_B64="$(printf '%s' "$ENV_DOCKER_PRODUCTION" | base64 | tr -d '\n')" REGISTRY_PASSWORD_B64="$(printf '%s' "$REGISTRY_PASSWORD" | base64 | tr -d '\n')" ssh $SSH_OPTIONS "${{ secrets.VPS_USER }}@$VPS_HOST" " set -e mkdir -p '$DEPLOY_ROOT' '$REMOTE_BUILD_DIR' if [ -n '$ENV_DOCKER_PRODUCTION_B64_CLEAN' ]; then printf '%s' '$ENV_DOCKER_PRODUCTION_B64_CLEAN' | base64 -d > '$DEPLOY_ROOT/.env.docker.production' chmod 600 '$DEPLOY_ROOT/.env.docker.production' elif [ -n '$ENV_DOCKER_PRODUCTION_RAW_B64' ]; then printf '%s' '$ENV_DOCKER_PRODUCTION_RAW_B64' | base64 -d > '$DEPLOY_ROOT/.env.docker.production' chmod 600 '$DEPLOY_ROOT/.env.docker.production' elif [ ! -f '$DEPLOY_ROOT/.env.docker.production' ]; then echo 'ENV_DOCKER_PRODUCTION or ENV_DOCKER_PRODUCTION_B64 is not set, and $DEPLOY_ROOT/.env.docker.production does not exist on the VPS' >&2 exit 1 else echo 'Using existing $DEPLOY_ROOT/.env.docker.production on the VPS' fi cp '$DEPLOY_ROOT/.env.docker.production' '$REMOTE_BUILD_DIR/.env.docker.production' chmod 600 '$REMOTE_BUILD_DIR/.env.docker.production' cd '$REMOTE_BUILD_DIR' export APP_IMAGE='$DEPLOY_REGISTRY_HOST/$IMAGE_REPOSITORY' export IMAGE_TAG='$IMAGE_TAG' export REGISTRY_HOST='$DEPLOY_REGISTRY_HOST' export REGISTRY_USER='$REGISTRY_USERNAME' export REGISTRY_PASSWORD=\"\$(printf '%s' '$REGISTRY_PASSWORD_B64' | base64 -d)\" bash scripts/docker-prod-deploy.sh "