From a3a49adad20189d1e56b40c6d45c3fe65238acaa Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Wed, 16 Sep 2026 10:23:07 +0330 Subject: [PATCH] chore: deploy once through Compose --- .gitattributes | 1 + .gitea/buildkitd.toml | 2 - .gitea/workflows/ci-cd.yml | 184 ++++++++----------------------- DEPLOYMENT.md | 172 +++++++++-------------------- compose.yaml | 2 +- scripts/deploy-release.sh | 215 +++++-------------------------------- src/server/backup.ts | 2 +- 7 files changed, 126 insertions(+), 452 deletions(-) create mode 100644 .gitattributes delete mode 100644 .gitea/buildkitd.toml diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfdb8b7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf diff --git a/.gitea/buildkitd.toml b/.gitea/buildkitd.toml deleted file mode 100644 index f6dc454..0000000 --- a/.gitea/buildkitd.toml +++ /dev/null @@ -1,2 +0,0 @@ -[worker.oci] - max-parallelism = 1 diff --git a/.gitea/workflows/ci-cd.yml b/.gitea/workflows/ci-cd.yml index 1c5dc1b..ade9669 100644 --- a/.gitea/workflows/ci-cd.yml +++ b/.gitea/workflows/ci-cd.yml @@ -1,150 +1,60 @@ -name: Test and deploy +name: Build and deploy on: pull_request: - branches: - - main + branches: [main] push: - branches: - - main + branches: [main] workflow_dispatch: jobs: - verify: - name: Verify production build - if: gitea.event_name != 'pull_request' || gitea.event.pull_request.head.repo.full_name == gitea.repository + build: + name: Production build and types + if: gitea.event_name == 'workflow_dispatch' || (gitea.event_name == 'pull_request' && gitea.event.pull_request.head.repo.full_name == gitea.repository) runs-on: portfolio-ci timeout-minutes: 15 env: - BUILDER_NAME: portfolio-builder - VERIFY_IMAGE: portfolio-verify:${{ gitea.sha }} - RUNTIME_IMAGE: amiirkhl-portfolio:${{ gitea.sha }} - NPM_REGISTRY: https://package-mirror.liara.ir/repository/npm/ + PORTFOLIO_IMAGE: portfolio-check:${{ gitea.sha }} + NPM_REGISTRY: ${{ vars.NPM_REGISTRY }} steps: - - name: Check out repository - uses: actions/checkout@v4 - - - name: Prepare persistent BuildKit builder - shell: bash - run: | - set -Eeuo pipefail - if ! docker buildx inspect "$BUILDER_NAME" >/dev/null 2>&1; then - docker buildx create \ - --name "$BUILDER_NAME" \ - --driver docker-container \ - --buildkitd-config .gitea/buildkitd.toml - fi - docker buildx use "$BUILDER_NAME" - docker buildx inspect --bootstrap - - - name: Verify application once - shell: bash - run: | - set -Eeuo pipefail - docker buildx build \ - --builder "$BUILDER_NAME" \ - --target verification \ - --build-arg "NPM_REGISTRY=$NPM_REGISTRY" \ - --tag "$VERIFY_IMAGE" \ - --load \ - --progress plain \ - . - - - name: Audit dependencies - shell: bash - run: | - set -Eeuo pipefail - docker run --rm "$VERIFY_IMAGE" \ - npm audit \ - --registry="$NPM_REGISTRY" \ - --audit-level=high - - - name: Create immutable runtime image - shell: bash - run: | - set -Eeuo pipefail - docker buildx build \ - --builder "$BUILDER_NAME" \ - --target runtime \ - --build-arg "NPM_REGISTRY=$NPM_REGISTRY" \ - --tag "$RUNTIME_IMAGE" \ - --load \ - --progress plain \ - . - - - name: Smoke-test runtime image - shell: bash - run: bash scripts/smoke-container.sh "$RUNTIME_IMAGE" - - - name: Configure deployment SSH - if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/main' - shell: bash - env: - DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} - DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }} - SSH_PRIVATE_KEY: ${{ secrets.DEPLOY_SSH_KEY }} - run: | - set -Eeuo pipefail - : "${DEPLOY_HOST:?DEPLOY_HOST secret is required}" - : "${DEPLOY_KNOWN_HOSTS:?DEPLOY_KNOWN_HOSTS secret is required}" - : "${SSH_PRIVATE_KEY:?DEPLOY_SSH_KEY secret is required}" - - install -d -m 700 "$HOME/.ssh" - printf '%s\n' "$SSH_PRIVATE_KEY" > "$HOME/.ssh/deploy_key" - chmod 600 "$HOME/.ssh/deploy_key" - printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > "$HOME/.ssh/known_hosts" - chmod 600 "$HOME/.ssh/known_hosts" - - - name: Upload release manifest - if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/main' - shell: bash - env: - DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} - DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }} - DEPLOY_USER: ${{ secrets.DEPLOY_USER }} - run: | - set -Eeuo pipefail - : "${DEPLOY_USER:?DEPLOY_USER secret is required}" - DEPLOY_PORT="${DEPLOY_PORT:-22}" - scp \ - -i "$HOME/.ssh/deploy_key" \ - -o BatchMode=yes \ - -o StrictHostKeyChecking=yes \ - -P "$DEPLOY_PORT" \ - compose.yaml \ - "$DEPLOY_USER@$DEPLOY_HOST:/tmp/portfolio-${{ gitea.sha }}-compose.yaml" - - - name: Activate verified image - if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/main' - shell: bash - env: - DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} - DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }} - DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }} - DEPLOY_USER: ${{ secrets.DEPLOY_USER }} - run: | - set -Eeuo pipefail - : "${DEPLOY_PATH:?DEPLOY_PATH secret is required}" - DEPLOY_PORT="${DEPLOY_PORT:-22}" - remote_compose="/tmp/portfolio-${{ gitea.sha }}-compose.yaml" - printf -v remote_command \ - 'bash -s -- %q %q %q %q %q' \ - "$DEPLOY_PATH" \ - "${{ gitea.sha }}" \ - "$RUNTIME_IMAGE" \ - "${{ gitea.run_number }}" \ - "$remote_compose" - - ssh \ - -i "$HOME/.ssh/deploy_key" \ - -o BatchMode=yes \ - -o StrictHostKeyChecking=yes \ - -p "$DEPLOY_PORT" \ - "$DEPLOY_USER@$DEPLOY_HOST" \ - "$remote_command" \ - < scripts/deploy-release.sh - - - name: Remove temporary verification image + - uses: actions/checkout@v4 + - name: Compile once, including TypeScript validation + run: docker compose build + - name: Remove check image if: always() + run: docker image rm "$PORTFOLIO_IMAGE" >/dev/null 2>&1 || true + + deploy: + name: Deploy with Compose + if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/main' + runs-on: portfolio-ci + timeout-minutes: 15 + env: + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }} + DEPLOY_USER: ${{ secrets.DEPLOY_USER }} + DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }} + DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} + DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }} + REVISION: ${{ gitea.sha }} + steps: + - uses: actions/checkout@v4 + - name: Update checkout and build on the server shell: bash - run: docker image rm "$VERIFY_IMAGE" >/dev/null 2>&1 || true + run: | + set -Eeuo pipefail + : "${DEPLOY_HOST:?Missing DEPLOY_HOST}" + : "${DEPLOY_USER:?Missing DEPLOY_USER}" + : "${DEPLOY_PATH:?Missing DEPLOY_PATH}" + : "${DEPLOY_SSH_KEY:?Missing DEPLOY_SSH_KEY}" + : "${DEPLOY_KNOWN_HOSTS:?Missing DEPLOY_KNOWN_HOSTS}" + ssh_directory="$(mktemp -d)" + trap 'rm -rf -- "$ssh_directory"' EXIT + printf '%s\n' "$DEPLOY_SSH_KEY" > "$ssh_directory/key" + printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > "$ssh_directory/known_hosts" + chmod 600 "$ssh_directory/key" + printf -v command 'bash -s -- %q %q' "$DEPLOY_PATH" "$REVISION" + ssh -i "$ssh_directory/key" -p "${DEPLOY_PORT:-22}" \ + -o BatchMode=yes -o StrictHostKeyChecking=yes \ + -o "UserKnownHostsFile=$ssh_directory/known_hosts" \ + "$DEPLOY_USER@$DEPLOY_HOST" "$command" < scripts/deploy-release.sh diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index ca239e8..bca419b 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -1,142 +1,72 @@ # Deployment -For the portfolio builder's first installation, owner commands, persistent -SQLite storage, and backup/restore, see [SELF_HOSTING.md](SELF_HOSTING.md). -Set the public `SITE_URL` in `/runtime.env` before deployment. +## Install or update -The Gitea Actions workflow in `.gitea/workflows/ci-cd.yml` verifies every pull -request and every push to `main`. A successful push to `main` builds one -verified Docker image on the shared host daemon, smoke-tests it, and activates -that exact commit-tagged image over SSH without uploading source or rebuilding. +On a server with Docker Engine and Compose v2, clone/pull the repository, copy +`.env.example` to `.env`, set `SITE_URL`, and run: -Release manifests are stored in `/releases/`. Each -manifest records the immutable image tag and Gitea run number. The `current` -symlink is changed only after the new container is healthy. If startup fails, -the deployment script stops short of automatic rollback: startup may have -migrated the database. Restore the pre-upgrade backup with the matching older -image using the procedure in `SELF_HOSTING.md`. -After health succeeds, the Git checkout at `` is fast-forwarded to -the exact deployed commit. Deployment refuses to overwrite tracked server-side -changes or a checkout that has diverged from `origin/main`. +```sh +docker compose up --build -d +``` -## Gitea Actions setup +Create the owner separately when needed: -Enable **Repository Actions** in the repository settings and register the -dedicated trusted runner maintained in -`https://git.amiirkhl.ir/amiirkhl/portfolio-gitea-runner`. The -workflow uses the `portfolio-ci` label. Its reusable job image provides Node.js -22, Bash, Git, OpenSSH, Docker CLI, Buildx, and Compose. Runner capacity remains -one because Gitea, builds, and production share a 2-core/4-GB server. +```sh +docker compose exec portfolio-app node maintenance.mjs create-user +``` -The runner and job container use the host Docker socket. Restrict the label to -this trusted repository and disable Actions for fork pull requests. The workflow -also skips pull requests whose head repository differs from this repository. +`create-owner` and `reset-password` remain available. Startup never provisions +an account. See [SELF_HOSTING.md](SELF_HOSTING.md) for private imports, persistent +volumes, optional Caddy integration, backups, and the one-time upgrade steps. -After the first successful verification run, protect `main` in Gitea and make -the **Verify production build** status check required. This prevents a pull -request from merging when linting, type-checking, tests, the production build, -or the dependency audit fails. +## Gitea Actions -## Gitea repository secrets +Keep the existing trusted `portfolio-ci` runner with Docker/Compose, Bash, Git, +and OpenSSH. No dedicated Buildx builder, shared image daemon, release manifests, +or release-directory symlinks are required. The deployment server needs Git, +Bash, `flock`, Docker/Compose, access to pull this repository, and a checkout on +`main` at `DEPLOY_PATH`. Configure its `.env` once, just as for manual deployment. -Add these secrets under the repository's **Settings > Actions > Secrets**: +Keep these repository Actions secrets: | Secret | Value | | --- | --- | -| `DEPLOY_HOST` | Server hostname or IP address | +| `DEPLOY_HOST` | Server hostname/IP | | `DEPLOY_USER` | SSH user allowed to run Docker | -| `DEPLOY_PORT` | SSH port; optional, defaults to `22` | -| `DEPLOY_PATH` | Absolute server directory, for example `/srv/portfolio` | -| `DEPLOY_SSH_KEY` | Private SSH key for the deployment user | -| `DEPLOY_KNOWN_HOSTS` | Pinned SSH host-key line for the server | +| `DEPLOY_PORT` | Optional SSH port, default 22 | +| `DEPLOY_PATH` | Absolute server checkout directory | +| `DEPLOY_SSH_KEY` | Deployment SSH private key | +| `DEPLOY_KNOWN_HOSTS` | Verified server SSH host-key entry | -Generate the known-hosts value from a trusted machine and verify its fingerprint -before saving it: +The server's checkout needs its own Git read access; the deployment SSH key is +only for reaching the server. Protect the trusted runner from untrusted fork +code. Fork pull requests do not run this workflow. -```sh -ssh-keyscan -p 22 your-server.example.com -``` +- **Pull request or manual workflow:** build the production image, including + TypeScript validation. No browser tests, unit suites, lint, or audit jobs. +- **Push to main:** SSH to the server, serialize deployments, fast-forward to + the triggering commit, back up an existing running installation, then run + `docker compose up --build -d --remove-orphans --wait --wait-timeout 60`. + An obsolete queued commit is skipped. The image is tagged with its commit SHA. -Do not copy an unverified key directly into the secret; doing so would remove -the protection against a man-in-the-middle attack. +Each deployment builds once on the server, where Docker retains its dependency, +native-library, and Next.js caches. `NPM_REGISTRY` in the server `.env` selects a +mirror if needed. The optional Gitea variable of the same name configures PR +builds. Avoid pruning build caches between deployments. -## One-time server setup +## Timing and recovery -The server needs Docker Engine with the Compose v2 and Buildx plugins. The -runner and deployment user must reach the same Docker daemon, and the deployment -user must run `docker` without an interactive password prompt. +The target is under 180 seconds for cached UI-change deployments. Cold builds, +network transfers, native-library upgrades, and large backups may take longer. +This change does not benchmark or enforce a wall-clock guarantee. Job timeouts +allow a first build to finish rather than killing it at the routine-update target. -Create the deployment root and the external network once: +Backups are retained under `backups/before--` in the server +checkout and are ignored by Git. They contain the database, uploads, and generated +secret; preserve an externally configured secret separately. Copy backups to +separate storage through your normal backup process. -```sh -sudo mkdir -p /srv/portfolio/releases -sudo chown -R deploy:deploy /srv/portfolio -docker network create caddy_proxy -``` - -Replace `deploy:deploy` and `/srv/portfolio` with the values used for -`DEPLOY_USER` and `DEPLOY_PATH`. Add the public half of `DEPLOY_SSH_KEY` to the -deployment user's `~/.ssh/authorized_keys`. - -The standalone Next.js container joins `caddy_proxy` under the `portfolio-app` -service name. Caddy must also join that network and proxy to -`portfolio-app:3000`. No host port is published by this project. - -A minimal Caddy route is: - -```caddyfile -portfolio.example.com { - reverse_proxy portfolio-app:3000 -} -``` - -Caddy forwards the browser's `Accept` header by default; keep that behavior so -Next.js can negotiate WebP responses. Do not replace the cache headers emitted -for `/_next/image`. The named `next-image-cache` volume persists `.next/cache` -between container replacements so optimized variants can be reused. - -The application expects immutable media URLs. When an admin or future upload -workflow replaces an image, write it to a UUID or content-versioned URL instead -of overwriting the previous object at the same URL. Next.js has no per-image -optimizer-cache invalidation API. - -## Manual deployment - -For an emergency manual deployment from a checkout on the server: - -```sh -docker compose \ - --project-name portfolio \ - --file compose.yaml \ - --file compose.build.yaml \ - up --detach --build --remove-orphans -``` - -This legacy path builds `amiirkhl-portfolio:latest` and is intentionally kept -outside the normal CI deployment. Existing source-based release directories can -still be started with their original Compose file during migration. Rollback is -manual: verify schema compatibility or restore the pre-upgrade backup together -with its matching application image. Older release directories do not contain -`release.env` image metadata. - -## Pipeline timing verification - -After migrating the runner, record one cold run and three normal source-change -runs. Expand the **Set up job** and **Complete job** sections in Gitea when -capturing the results. For each run, record checkout, verification build, audit, -runtime image creation, smoke test, deployment health wait, and completion time. -All three warm runs should finish below 180 seconds without OOM events or -production health failures. If they do not, collect `docker stats`, `free -h`, -`df -h`, `docker system df`, `docker buildx du --builder portfolio-builder`, and -the runner logs before changing or removing any quality gate. - -Old versioned release directories are deliberately retained for rollback. They -can be removed manually after a newer release has been proven stable; keep the -directory referenced by the `current` symlink. - -## Runtime verification - -The production image contains `.next/standalone`, `.next/static`, and `public`, -and runs the generated `server.js` with Node.js 22 on port 3000. CI smoke-tests -the English and Persian routes, a built static asset, and `/_next/image` with -WebP negotiation before deployment. +If startup fails after a schema migration, do not simply downgrade the image. +Restore the matching pre-upgrade backup and older image using the recovery +instructions in SELF_HOSTING.md. A build failure leaves the running container +intact; the checkout remains at the attempted commit so the deployment can be retried. diff --git a/compose.yaml b/compose.yaml index 4b20b2b..943b41a 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,7 +1,7 @@ name: portfolio services: portfolio-app: - image: portfolio:local + image: ${PORTFOLIO_IMAGE:-portfolio:local} build: context: . target: runtime diff --git a/scripts/deploy-release.sh b/scripts/deploy-release.sh index 7a2f229..aa6defb 100644 --- a/scripts/deploy-release.sh +++ b/scripts/deploy-release.sh @@ -1,198 +1,33 @@ #!/usr/bin/env bash set -Eeuo pipefail -deploy_root="${1:?deployment root is required}" -release_id="${2:?release id is required}" -image="${3:?runtime image is required}" -sequence="${4:?deployment sequence is required}" -incoming_compose="${5:?incoming Compose file is required}" - -if [[ "$deploy_root" != /* || "$deploy_root" == "/" ]]; then - echo "DEPLOY_PATH must be an absolute directory other than /." >&2 - exit 2 -fi -if [[ ! "$release_id" =~ ^[0-9a-f]{7,64}$ ]]; then - echo "The release id must be a Git commit SHA." >&2 - exit 2 -fi -if [[ "$image" != "amiirkhl-portfolio:$release_id" ]]; then - echo "The runtime image must be tagged with the release id." >&2 - exit 2 -fi -if [[ ! "$sequence" =~ ^[1-9][0-9]*$ ]]; then - echo "The deployment sequence must be a positive integer." >&2 - exit 2 -fi -if [[ ! -f "$incoming_compose" ]]; then - echo "Incoming Compose file not found: $incoming_compose" >&2 - exit 2 -fi -if ! command -v flock >/dev/null 2>&1; then - echo "flock is required to serialize deployments." >&2 - exit 2 -fi - -releases_dir="$deploy_root/releases" -release_dir="$releases_dir/$release_id" -current_link="$deploy_root/current" -staging_dir="$releases_dir/.incoming-$release_id-$$" - -cleanup() { - rm -f -- "$incoming_compose" - if [[ -d "$staging_dir" ]]; then - rm -rf -- "$staging_dir" - fi -} -trap cleanup EXIT - -mkdir -p -- "$releases_dir" -exec 9>"$deploy_root/.deploy.lock" +deploy_root="${1:?deployment checkout is required}" +revision="${2:?commit SHA is required}" +[[ "$deploy_root" == /* && "$deploy_root" != / ]] || { echo 'Use an absolute DEPLOY_PATH.' >&2; exit 2; } +[[ "$revision" =~ ^[0-9a-f]{40,64}$ ]] || { echo 'Invalid commit SHA.' >&2; exit 2; } +cd -- "$deploy_root" +# Serialize manual and CI deployments; old queued runs cannot replace newer code. +exec 9>.deploy.lock flock -x 9 - -sync_checkout() { - local branch remote_release - - if [[ ! -d "$deploy_root/.git" ]]; then - echo "Deployment root is not a Git checkout: $deploy_root" >&2 - return 1 - fi - if ! git -C "$deploy_root" diff --quiet --ignore-submodules -- \ - || ! git -C "$deploy_root" diff --cached --quiet --ignore-submodules --; then - echo "Refusing to update a checkout with tracked changes: $deploy_root" >&2 - return 1 - fi - - branch="$(git -C "$deploy_root" symbolic-ref --quiet --short HEAD || true)" - if [[ "$branch" != "main" ]]; then - echo "Deployment checkout must be on main, not ${branch:-detached HEAD}." >&2 - return 1 - fi - - git -C "$deploy_root" fetch --quiet \ - origin "+refs/heads/main:refs/remotes/origin/main" - remote_release="$(git -C "$deploy_root" rev-parse refs/remotes/origin/main)" - if [[ "$remote_release" != "$release_id" ]]; then - echo "origin/main is $remote_release, expected deployed release $release_id." >&2 - return 1 - fi - - git -C "$deploy_root" merge --ff-only --quiet refs/remotes/origin/main - if [[ "$(git -C "$deploy_root" rev-parse HEAD)" != "$release_id" ]]; then - echo "Checkout did not advance to deployed release $release_id." >&2 - return 1 - fi -} - -if ! docker image inspect "$image" >/dev/null 2>&1; then - echo "Verified runtime image is missing from the production Docker daemon: $image" >&2 - exit 1 -fi - -previous_release="" -if [[ -L "$current_link" ]]; then - previous_release="$(readlink -f -- "$current_link" || true)" -fi - -if [[ -n "$previous_release" && -f "$previous_release/release.env" ]]; then - current_sequence="$(sed -n 's/^DEPLOY_SEQUENCE=//p' "$previous_release/release.env")" - current_release="$(basename "$previous_release")" - if [[ "$current_release" == "$release_id" ]]; then - sync_checkout - echo "Release and server checkout are already active: $release_id" - exit 0 - fi - if [[ "$current_sequence" =~ ^[1-9][0-9]*$ ]] && (( sequence <= current_sequence )); then - echo "Refusing stale deployment sequence $sequence; current sequence is $current_sequence." >&2 - exit 1 - fi -fi - -if [[ ! -d "$release_dir" ]]; then - mkdir -- "$staging_dir" - install -m 600 "$incoming_compose" "$staging_dir/compose.yaml" - { - printf 'PORTFOLIO_IMAGE=%s\n' "$image" - printf 'DEPLOY_SEQUENCE=%s\n' "$sequence" - } > "$staging_dir/release.env" - mv -- "$staging_dir" "$release_dir" -fi - -if [[ ! -f "$release_dir/compose.yaml" || ! -f "$release_dir/release.env" ]]; then - echo "Release metadata is incomplete: $release_dir" >&2 - exit 2 -fi - -compose() { - local directory="$1" - shift - local runtime_environment=() - if [[ -f "$deploy_root/runtime.env" ]]; then - runtime_environment=(--env-file "$deploy_root/runtime.env") - fi - docker compose \ - --project-name portfolio \ - --file "$directory/compose.yaml" \ - "${runtime_environment[@]}" \ - --env-file "$directory/release.env" \ - "$@" -} - -wait_until_healthy() { - local directory="$1" - local container_id status - - container_id="$(compose "$directory" ps -q portfolio-app)" - if [[ -z "$container_id" ]]; then - echo "The portfolio-app container was not created." >&2 - return 1 - fi - - for _ in {1..30}; do - status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_id")" - case "$status" in - healthy) - return 0 - ;; - unhealthy|exited|dead) - echo "Container entered state: $status" >&2 - return 1 - ;; - esac - sleep 2 - done - - echo "Container did not become healthy within 60 seconds." >&2 - return 1 -} - -activate() { - local directory="$1" - local release_image - - release_image="$(sed -n 's/^PORTFOLIO_IMAGE=//p' "$directory/release.env")" - if [[ -z "$release_image" ]] || ! docker image inspect "$release_image" >/dev/null 2>&1; then - echo "Release image is unavailable: ${release_image:-}" >&2 - return 1 - fi - - compose "$directory" config --quiet - compose "$directory" up --detach --no-build --pull never --remove-orphans - wait_until_healthy "$directory" -} - -if activate "$release_dir"; then - new_link="$deploy_root/.current-$release_id-$$" - ln -s -- "$release_dir" "$new_link" - mv -Tf -- "$new_link" "$current_link" - if ! sync_checkout; then - echo "Deployment is healthy, but the server checkout could not be updated." >&2 - exit 1 - fi - echo "Deployment succeeded: $release_id ($image)" +[[ -f .env ]] || { echo 'Fill in .env before deploying.' >&2; exit 1; } +git diff --quiet && git diff --cached --quiet || { echo 'Commit or remove server checkout edits first.' >&2; exit 1; } +[[ "$(git symbolic-ref --short HEAD)" == main ]] || { echo 'The deployment checkout must be on main.' >&2; exit 1; } +git fetch origin main +if [[ "$(git rev-parse origin/main)" != "$revision" ]]; then + echo 'Skipping an outdated deployment; a newer main commit exists.' exit 0 fi +git merge --ff-only "$revision" +export PORTFOLIO_IMAGE="portfolio:$revision" -echo "Deployment failed. Automatic image rollback is disabled because startup may have migrated the persistent database." >&2 -echo "Inspect the failed container. Restore the pre-upgrade backup into a new data directory before activating an older image; see SELF_HOSTING.md." >&2 +# Keep a consistent backup before migrations can run in the new container. +container="$(docker compose ps -q portfolio-app)" +if [[ -n "$container" ]]; then + backup_name="before-$revision-$(date +%s)" + mkdir -p backups + docker compose exec -T portfolio-app node maintenance.mjs backup "/tmp/$backup_name" + docker cp "$container:/tmp/$backup_name" "backups/$backup_name" +fi -exit 1 +# Build once; Next's production compilation includes the type check. +docker compose up --build -d --remove-orphans --wait --wait-timeout 60 diff --git a/src/server/backup.ts b/src/server/backup.ts index e606921..6964914 100644 --- a/src/server/backup.ts +++ b/src/server/backup.ts @@ -32,7 +32,7 @@ export function restore(sourceDir: string, targetDir: string) { try { if (check.pragma('integrity_check', { simple: true }) !== 'ok') throw new ContentError('Database integrity check failed'); const rows = check.prepare('SELECT key FROM media').all() as { key: string }[]; - if (rows.some(row => !/^[0-9a-f-]{36}\.(pdf|webp)$/.test(row.key) || !existsSync(path.join(source, 'uploads', row.key)))) throw new ContentError('Backup is missing uploaded files'); + if (rows.some(row => !/^(?:[0-9a-f-]{36}\.(?:pdf|webp)|[0-9a-f]{64}\.(?:png|jpg|webp))$/.test(row.key) || !existsSync(path.join(source, 'uploads', row.key)))) throw new ContentError('Backup is missing uploaded files'); } finally { check.close(); } const staging = `${target}.restore-${Date.now()}`; mkdirSync(staging, { recursive: true, mode: 0o700 });