chore: deploy once through Compose

This commit is contained in:
2026-09-16 10:23:07 +03:30
parent b90161e504
commit a3a49adad2
7 changed files with 126 additions and 452 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
*.sh text eol=lf

View File

@@ -1,2 +0,0 @@
[worker.oci]
max-parallelism = 1

View File

@@ -1,150 +1,60 @@
name: Test and deploy name: Build and deploy
on: on:
pull_request: pull_request:
branches: branches: [main]
- main
push: push:
branches: branches: [main]
- main
workflow_dispatch: workflow_dispatch:
jobs: jobs:
verify: build:
name: Verify production build name: Production build and types
if: gitea.event_name != 'pull_request' || gitea.event.pull_request.head.repo.full_name == gitea.repository 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 runs-on: portfolio-ci
timeout-minutes: 15 timeout-minutes: 15
env: env:
BUILDER_NAME: portfolio-builder PORTFOLIO_IMAGE: portfolio-check:${{ gitea.sha }}
VERIFY_IMAGE: portfolio-verify:${{ gitea.sha }} NPM_REGISTRY: ${{ vars.NPM_REGISTRY }}
RUNTIME_IMAGE: amiirkhl-portfolio:${{ gitea.sha }}
NPM_REGISTRY: https://package-mirror.liara.ir/repository/npm/
steps: steps:
- name: Check out repository - uses: actions/checkout@v4
uses: actions/checkout@v4 - name: Compile once, including TypeScript validation
run: docker compose build
- name: Prepare persistent BuildKit builder - name: Remove check image
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
if: always() 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 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

View File

@@ -1,142 +1,72 @@
# Deployment # Deployment
For the portfolio builder's first installation, owner commands, persistent ## Install or update
SQLite storage, and backup/restore, see [SELF_HOSTING.md](SELF_HOSTING.md).
Set the public `SITE_URL` in `<DEPLOY_PATH>/runtime.env` before deployment.
The Gitea Actions workflow in `.gitea/workflows/ci-cd.yml` verifies every pull On a server with Docker Engine and Compose v2, clone/pull the repository, copy
request and every push to `main`. A successful push to `main` builds one `.env.example` to `.env`, set `SITE_URL`, and run:
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.
Release manifests are stored in `<DEPLOY_PATH>/releases/<commit-sha>`. Each ```sh
manifest records the immutable image tag and Gitea run number. The `current` docker compose up --build -d
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 `<DEPLOY_PATH>` 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`.
## Gitea Actions setup Create the owner separately when needed:
Enable **Repository Actions** in the repository settings and register the ```sh
dedicated trusted runner maintained in docker compose exec portfolio-app node maintenance.mjs create-user
`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.
The runner and job container use the host Docker socket. Restrict the label to `create-owner` and `reset-password` remain available. Startup never provisions
this trusted repository and disable Actions for fork pull requests. The workflow an account. See [SELF_HOSTING.md](SELF_HOSTING.md) for private imports, persistent
also skips pull requests whose head repository differs from this repository. volumes, optional Caddy integration, backups, and the one-time upgrade steps.
After the first successful verification run, protect `main` in Gitea and make ## Gitea Actions
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 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 | | Secret | Value |
| --- | --- | | --- | --- |
| `DEPLOY_HOST` | Server hostname or IP address | | `DEPLOY_HOST` | Server hostname/IP |
| `DEPLOY_USER` | SSH user allowed to run Docker | | `DEPLOY_USER` | SSH user allowed to run Docker |
| `DEPLOY_PORT` | SSH port; optional, defaults to `22` | | `DEPLOY_PORT` | Optional SSH port, default 22 |
| `DEPLOY_PATH` | Absolute server directory, for example `/srv/portfolio` | | `DEPLOY_PATH` | Absolute server checkout directory |
| `DEPLOY_SSH_KEY` | Private SSH key for the deployment user | | `DEPLOY_SSH_KEY` | Deployment SSH private key |
| `DEPLOY_KNOWN_HOSTS` | Pinned SSH host-key line for the server | | `DEPLOY_KNOWN_HOSTS` | Verified server SSH host-key entry |
Generate the known-hosts value from a trusted machine and verify its fingerprint The server's checkout needs its own Git read access; the deployment SSH key is
before saving it: only for reaching the server. Protect the trusted runner from untrusted fork
code. Fork pull requests do not run this workflow.
```sh - **Pull request or manual workflow:** build the production image, including
ssh-keyscan -p 22 your-server.example.com 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 Each deployment builds once on the server, where Docker retains its dependency,
the protection against a man-in-the-middle attack. 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 The target is under 180 seconds for cached UI-change deployments. Cold builds,
runner and deployment user must reach the same Docker daemon, and the deployment network transfers, native-library upgrades, and large backups may take longer.
user must run `docker` without an interactive password prompt. 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-<commit>-<timestamp>` 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 If startup fails after a schema migration, do not simply downgrade the image.
sudo mkdir -p /srv/portfolio/releases Restore the matching pre-upgrade backup and older image using the recovery
sudo chown -R deploy:deploy /srv/portfolio instructions in SELF_HOSTING.md. A build failure leaves the running container
docker network create caddy_proxy intact; the checkout remains at the attempted commit so the deployment can be retried.
```
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.

View File

@@ -1,7 +1,7 @@
name: portfolio name: portfolio
services: services:
portfolio-app: portfolio-app:
image: portfolio:local image: ${PORTFOLIO_IMAGE:-portfolio:local}
build: build:
context: . context: .
target: runtime target: runtime

View File

@@ -1,198 +1,33 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -Eeuo pipefail set -Eeuo pipefail
deploy_root="${1:?deployment root is required}" deploy_root="${1:?deployment checkout is required}"
release_id="${2:?release id is required}" revision="${2:?commit SHA is required}"
image="${3:?runtime image is required}" [[ "$deploy_root" == /* && "$deploy_root" != / ]] || { echo 'Use an absolute DEPLOY_PATH.' >&2; exit 2; }
sequence="${4:?deployment sequence is required}" [[ "$revision" =~ ^[0-9a-f]{40,64}$ ]] || { echo 'Invalid commit SHA.' >&2; exit 2; }
incoming_compose="${5:?incoming Compose file is required}" cd -- "$deploy_root"
# Serialize manual and CI deployments; old queued runs cannot replace newer code.
if [[ "$deploy_root" != /* || "$deploy_root" == "/" ]]; then exec 9>.deploy.lock
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"
flock -x 9 flock -x 9
[[ -f .env ]] || { echo 'Fill in .env before deploying.' >&2; exit 1; }
sync_checkout() { git diff --quiet && git diff --cached --quiet || { echo 'Commit or remove server checkout edits first.' >&2; exit 1; }
local branch remote_release [[ "$(git symbolic-ref --short HEAD)" == main ]] || { echo 'The deployment checkout must be on main.' >&2; exit 1; }
git fetch origin main
if [[ ! -d "$deploy_root/.git" ]]; then if [[ "$(git rev-parse origin/main)" != "$revision" ]]; then
echo "Deployment root is not a Git checkout: $deploy_root" >&2 echo 'Skipping an outdated deployment; a newer main commit exists.'
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 exit 0
fi fi
if [[ "$current_sequence" =~ ^[1-9][0-9]*$ ]] && (( sequence <= current_sequence )); then git merge --ff-only "$revision"
echo "Refusing stale deployment sequence $sequence; current sequence is $current_sequence." >&2 export PORTFOLIO_IMAGE="portfolio:$revision"
exit 1
fi # 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 fi
if [[ ! -d "$release_dir" ]]; then # Build once; Next's production compilation includes the type check.
mkdir -- "$staging_dir" docker compose up --build -d --remove-orphans --wait --wait-timeout 60
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:-<unset>}" >&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)"
exit 0
fi
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
exit 1

View File

@@ -32,7 +32,7 @@ export function restore(sourceDir: string, targetDir: string) {
try { try {
if (check.pragma('integrity_check', { simple: true }) !== 'ok') throw new ContentError('Database integrity check failed'); 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 }[]; 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(); } } finally { check.close(); }
const staging = `${target}.restore-${Date.now()}`; const staging = `${target}.restore-${Date.now()}`;
mkdirSync(staging, { recursive: true, mode: 0o700 }); mkdirSync(staging, { recursive: true, mode: 0o700 });