diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index be4d021e8..a8a9cb2e3 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -6,6 +6,7 @@ on: - main - develop - feature/vapor-env-updates + - feature/deploy-testing workflow_dispatch: inputs: environment: @@ -21,6 +22,15 @@ on: required: false default: false type: boolean + force_build: + description: 'Force a fresh DigitalOcean build (ignores the build cache)' + required: false + default: false + type: boolean + +concurrency: + group: deploy-${{ github.event_name == 'workflow_dispatch' && github.event.inputs.environment || (github.ref_name == 'main' && 'production' || 'staging') }} + cancel-in-progress: false jobs: backend: @@ -135,11 +145,13 @@ jobs: echo "DO_APP_ID=${{ secrets.DIGITALOCEAN_STAGING_APP_ID }}" >> "$GITHUB_ENV" echo "TEST_MODE=false" >> "$GITHUB_ENV" fi + echo "FORCE_BUILD=${{ github.event_name == 'workflow_dispatch' && github.event.inputs.force_build || 'false' }}" >> "$GITHUB_ENV" - name: Log Environment Settings run: | echo "๐Ÿš€ Deploying frontend to DigitalOcean App: ${{ env.DO_APP_ID }}" echo "๐Ÿงช Test mode: ${{ env.TEST_MODE }}" + echo "๐Ÿ”จ Force build: ${{ env.FORCE_BUILD }}" - name: Validate Deployment Configuration (Test Mode) if: env.TEST_MODE == 'true' @@ -154,7 +166,8 @@ jobs: run: | RESPONSE=$(curl -s -o response.json -w "%{http_code}" -X POST "https://api.digitalocean.com/v2/apps/$DO_APP_ID/deployments" \ -H "Authorization: Bearer ${{ secrets.DIGITALOCEAN_API_TOKEN }}" \ - -H "Content-Type: application/json") + -H "Content-Type: application/json" \ + -d "{\"force_build\": $FORCE_BUILD}") if [ "$RESPONSE" -ne 201 ] && [ "$RESPONSE" -ne 200 ]; then ERROR_MSG=$(jq -r '.message // "Unknown error occurred."' response.json) @@ -176,7 +189,7 @@ jobs: - name: Poll Deployment Status if: env.TEST_MODE != 'true' run: | - MAX_RETRIES=60 + MAX_RETRIES=90 SLEEP_TIME=10 COUNTER=0 @@ -185,18 +198,31 @@ jobs: -H "Authorization: Bearer ${{ secrets.DIGITALOCEAN_API_TOKEN }}" \ -H "Content-Type: application/json") - STATUS=$(echo "$RESPONSE" | jq -r '.deployment.phase') + STATUS=$(echo "$RESPONSE" | jq -r '.deployment.phase // "UNKNOWN"') echo "๐Ÿ”„ Deployment Status: $STATUS" - if [ "$STATUS" == "ACTIVE" ]; then - echo "โœ… Deployment completed successfully." - exit 0 - elif [[ "$STATUS" == "FAILED" || "$STATUS" == "CANCELED" ]]; then - echo "โŒ Deployment failed or was cancelled." - exit 1 + if [ -z "${DEPLOYMENT_LOGGED:-}" ] && [ "$STATUS" != "UNKNOWN" ]; then + echo "๐Ÿ“ฆ $(echo "$RESPONSE" | jq -r '.deployment | "cause=\(.cause // "unknown") build_reused_from=\(.cloned_from // "none")"')" + echo "๐Ÿ”– $(echo "$RESPONSE" | jq -r '[.deployment | ((.services//[]) + (.workers//[]) + (.jobs//[]) + (.static_sites//[])) | .[] | "\(.name)@\(.source_commit_hash[0:7] // "?")"] | join(" ") | if . == "" then "(no components reported)" else . end')" + DEPLOYMENT_LOGGED=1 fi + case "$STATUS" in + ACTIVE) + echo "โœ… Deployment completed successfully." + exit 0 + ;; + SUPERSEDED) + echo "โญ๏ธ Superseded by a newer deployment, which is now responsible for this app." + exit 0 + ;; + ERROR|CANCELED) + echo "โŒ Deployment finished in state: $STATUS" + exit 1 + ;; + esac + COUNTER=$((COUNTER + 1)) echo "โณ Retrying in $SLEEP_TIME seconds... ($COUNTER/$MAX_RETRIES)" sleep $SLEEP_TIME diff --git a/frontend/Dockerfile.ssr b/frontend/Dockerfile.ssr index d61710ed7..425c0c066 100644 --- a/frontend/Dockerfile.ssr +++ b/frontend/Dockerfile.ssr @@ -1,18 +1,47 @@ -FROM node:alpine as build-stage +# syntax=docker/dockerfile:1.7 -WORKDIR /app +FROM node:22-alpine AS deps -RUN apk add --no-cache yarn +WORKDIR /app COPY package.json yarn.lock ./ -RUN yarn install +RUN --mount=type=cache,target=/usr/local/share/.cache/yarn,sharing=locked \ + yarn install --frozen-lockfile + +FROM node:22-alpine AS build + +WORKDIR /app + +COPY --from=deps /app/node_modules ./node_modules COPY . . -RUN yarn build +RUN yarn messages:compile \ + && yarn build:ssr:client \ + && yarn build:ssr:server + +FROM node:22-alpine AS prod-deps + +WORKDIR /app + +COPY package.json yarn.lock ./ + +RUN --mount=type=cache,target=/usr/local/share/.cache/yarn,sharing=locked \ + yarn install --frozen-lockfile --production + +FROM node:22-alpine AS runtime + +ENV NODE_ENV=production + +WORKDIR /app + +COPY --from=prod-deps /app/node_modules ./node_modules +COPY --from=build /app/dist ./dist +COPY package.json yarn.lock server.js ./ +COPY src ./src +COPY public ./public EXPOSE 5678 CMD ["yarn", "start"] - diff --git a/frontend/package.json b/frontend/package.json index 9c6bccff2..c208a952a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,6 +21,7 @@ "@dnd-kit/core": "^6.0.8", "@dnd-kit/sortable": "^7.0.2", "@dnd-kit/utilities": "^3.2.1", + "@lingui/core": "4.14.1", "@lingui/macro": "^4.7.0", "@lingui/react": "^4.7.0", "@mantine/carousel": "^9.2.2", @@ -61,18 +62,23 @@ "dayjs": "^1.11.8", "dotenv": "^16.4.7", "express": "^4.19.2", + "invariant": "2.2.4", + "lodash": "4.17.21", "qr-scanner": "^1.4.2", "query-string": "^8.1.0", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-fast-compare": "3.2.2", "react-helmet-async": "^2.0.4", "react-qr-code": "^2.0.12", "react-router": "^7.1.5", "react-router-dom": "^7.1.5", "recharts": "^3", + "shallowequal": "1.1.0", "sirv": "^2.0.4" }, "devDependencies": { + "@lingui/cli": "4.14.1", "@lingui/vite-plugin": "^4.14.1", "@swc/core": "1.11.24", "@types/express": "^4.17.21", diff --git a/frontend/server.js b/frontend/server.js index 7f2f9b25f..e971f0858 100644 --- a/frontend/server.js +++ b/frontend/server.js @@ -1,7 +1,6 @@ import express from "express"; import {installGlobals} from "@remix-run/node"; import process from "process"; -import {createServer as viteServer} from "vite"; import compression from "compression"; import fs from "node:fs/promises"; import sirv from "sirv"; @@ -55,6 +54,8 @@ async function main() { let vite; if (!isProduction) { + const {createServer: viteServer} = await import("vite"); + vite = await viteServer({ server: { middlewareMode: true }, appType: "custom", diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 37af90973..d54461420 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -3562,7 +3562,7 @@ inquirer@^7.3.3: resolved "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz" integrity sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg== -invariant@^2.2.4: +invariant@2.2.4, invariant@^2.2.4: version "2.2.4" resolved "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz" integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA== @@ -3847,7 +3847,7 @@ lodash.sortby@^4.7.0: resolved "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz" integrity sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA== -lodash@^4.17.19: +lodash@4.17.21, lodash@^4.17.19: version "4.17.21" resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== @@ -4551,7 +4551,7 @@ react-dropzone@15.0.0: file-selector "^2.1.0" prop-types "^15.8.1" -react-fast-compare@^3.2.2: +react-fast-compare@3.2.2, react-fast-compare@^3.2.2: version "3.2.2" resolved "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz" integrity sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ== @@ -4931,7 +4931,7 @@ setprototypeof@1.2.0: resolved "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz" integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== -shallowequal@^1.1.0: +shallowequal@1.1.0, shallowequal@^1.1.0: version "1.1.0" resolved "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz" integrity sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==