chore: deploy once through Compose
This commit is contained in:
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
*.sh text eol=lf
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
[worker.oci]
|
|
||||||
max-parallelism = 1
|
|
||||||
@@ -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
|
||||||
|
|||||||
172
DEPLOYMENT.md
172
DEPLOYMENT.md
@@ -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.
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
Reference in New Issue
Block a user