Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .cicd/deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ set -eu

APP_DIR="${APP_DIR:-$(pwd)}"
COMPOSE_FILE="${COMPOSE_FILE:-compose.yaml}"
DOCKERFILE="${DOCKERFILE:-Dockerfile}"
IMAGE_NAME="${IMAGE_NAME:-kairoreader.com:production}"
HEALTH_TIMEOUT_SECONDS="${HEALTH_TIMEOUT_SECONDS:-150}"

export PATH="/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:${PATH:-}"
Expand Down Expand Up @@ -96,6 +98,7 @@ if ! command -v iptables >/dev/null 2>&1; then
fi

[ -f "$COMPOSE_FILE" ] || fail "missing $COMPOSE_FILE"
[ -f "$DOCKERFILE" ] || fail "missing $DOCKERFILE"

site_domain="${SITE_DOMAIN:-$(env_value SITE_DOMAIN .env)}"
acme_email="${ACME_EMAIL:-$(env_value ACME_EMAIL .env)}"
Expand All @@ -118,7 +121,7 @@ fi
printf 'SITE_DOMAIN=%s\nACME_EMAIL=%s\n' "$normalized_domains" "$acme_email" > .env

printf 'Deploying Kairo Reader for %s\n' "$normalized_domains"
nerdctl_run compose -f "$COMPOSE_FILE" build
nerdctl_run build --pull=true --file "$DOCKERFILE" --tag "$IMAGE_NAME" .
nerdctl_run compose -f "$COMPOSE_FILE" down
nerdctl_run compose -f "$COMPOSE_FILE" up -d

Expand Down
52 changes: 52 additions & 0 deletions .cicd/update-vps.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
#!/usr/bin/env sh
set -eu

fail() {
printf 'update-vps: %s\n' "$*" >&2
exit 1
}

if [ "$(id -u)" -ne 0 ]; then
fail "run this as root or with passwordless sudo"
fi

if [ -r /etc/os-release ]; then
. /etc/os-release
else
fail "cannot detect Linux distribution"
fi

case "${ID:-}" in
debian|ubuntu) ;;
*) fail "automatic package maintenance supports Debian and Ubuntu only, not ${ID:-unknown}" ;;
esac

export DEBIAN_FRONTEND=noninteractive
export NEEDRESTART_MODE=a

printf 'Refreshing APT package metadata on %s\n' "$(hostname)"
apt-get update

upgrade_list="$(apt-get -s --with-new-pkgs upgrade 2>/dev/null | awk '/^Inst / { print $2 }')"
upgrade_count="$(printf '%s\n' "$upgrade_list" | awk 'NF { count++ } END { print count + 0 }')"

if [ "$upgrade_count" -eq 0 ]; then
printf 'VPS packages are already up to date.\n'
else
printf 'Applying %s safe package update(s):\n' "$upgrade_count"
printf '%s\n' "$upgrade_list" | sed '/^$/d; s/^/ - /'
apt-get -y -o Dpkg::Options::=--force-confold --with-new-pkgs upgrade
apt-get check
fi

remaining="$(apt-get -s --with-new-pkgs upgrade 2>/dev/null | awk '/^Inst / { print $2 }')"
if [ -n "$remaining" ]; then
printf '::warning::Some VPS packages remain held back and need manual review: %s\n' "$(printf '%s' "$remaining" | tr '\n' ' ')"
fi

if [ -f /var/run/reboot-required ]; then
printf '::warning::VPS package updates require a reboot; reboot was not automated to avoid unplanned downtime.\n'
if [ -r /var/run/reboot-required.pkgs ]; then
sed 's/^/ - /' /var/run/reboot-required.pkgs
fi
fi
47 changes: 47 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
version: 2

updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
day: monday
time: "03:15"
timezone: Europe/London
groups:
npm-minor-and-patch:
patterns:
- "*"
update-types:
- minor
- patch

- package-ecosystem: docker
directory: /
schedule:
interval: weekly
day: monday
time: "03:30"
timezone: Europe/London
groups:
container-minor-and-patch:
patterns:
- "*"
update-types:
- minor
- patch

- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
day: monday
time: "03:45"
timezone: Europe/London
groups:
actions-minor-and-patch:
patterns:
- "*"
update-types:
- minor
- patch
39 changes: 39 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: CI

on:
pull_request:
branches:
- main
workflow_dispatch:

permissions:
contents: read

concurrency:
group: ci-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

jobs:
validate:
name: Validate site
runs-on: ubuntu-latest
timeout-minutes: 15

steps:
- name: Checkout
uses: actions/checkout@v6

- name: Set up Node
uses: actions/setup-node@v6
with:
node-version: 24
cache: npm

- name: Set up npm
run: npm install --global npm@12

- name: Install dependencies
run: npm ci

- name: Check and build
run: npm run deploy:check
94 changes: 94 additions & 0 deletions .github/workflows/dependency-updates.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
name: Dependency updates

on:
pull_request_target:
types:
- opened
- reopened
- synchronize
workflow_run:
workflows:
- CI
types:
- completed

jobs:
classify:
name: Classify Dependabot update
if: >-
github.event_name == 'pull_request_target' &&
github.event.pull_request.user.login == 'dependabot[bot]'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: write

steps:
- name: Read Dependabot metadata
id: metadata
uses: dependabot/fetch-metadata@v3
with:
github-token: ${{ secrets.GITHUB_TOKEN }}

- name: Mark minor and patch updates for automatic merge
if: >-
steps.metadata.outputs.update-type == 'version-update:semver-minor' ||
steps.metadata.outputs.update-type == 'version-update:semver-patch'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_URL: ${{ github.event.pull_request.html_url }}
run: |
gh label create dependencies-automerge \
--repo "$GITHUB_REPOSITORY" \
--color 1d76db \
--description "Dependabot update that can merge after CI" \
--force
gh pr edit "$PR_URL" --add-label dependencies-automerge

- name: Require review for major updates
if: steps.metadata.outputs.update-type == 'version-update:semver-major'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_URL: ${{ github.event.pull_request.html_url }}
run: gh pr edit "$PR_URL" --remove-label dependencies-automerge || true

merge:
name: Merge validated dependency update
if: >-
github.event_name == 'workflow_run' &&
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.pull_requests[0] != null
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write

steps:
- name: Merge eligible Dependabot pull request
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number }}
VALIDATED_SHA: ${{ github.event.workflow_run.head_sha }}
run: |
author="$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json author --jq '.author.login')"
head_sha="$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json headRefOid --jq '.headRefOid')"
eligible="$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json labels --jq 'any(.labels[]; .name == "dependencies-automerge")')"

if [ "$author" != 'dependabot' ] && [ "$author" != 'dependabot[bot]' ] && [ "$author" != 'app/dependabot' ]; then
echo "Skipping non-Dependabot pull request #$PR_NUMBER."
exit 0
fi

if [ "$head_sha" != "$VALIDATED_SHA" ]; then
echo "Skipping pull request #$PR_NUMBER because CI validated an older commit."
exit 0
fi

if [ "$eligible" != 'true' ]; then
echo "Pull request #$PR_NUMBER requires review."
exit 0
fi

gh pr merge "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --squash --delete-branch
11 changes: 7 additions & 4 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ on:

concurrency:
group: production
cancel-in-progress: true
cancel-in-progress: false

jobs:
deploy:
Expand All @@ -27,14 +27,17 @@ jobs:

steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6

- name: Set up Node
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: 20
node-version: 24
cache: npm

- name: Set up npm
run: npm install --global npm@12

- name: Install dependencies
run: npm ci

Expand Down
88 changes: 88 additions & 0 deletions .github/workflows/vps-maintenance.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
name: VPS maintenance

on:
schedule:
- cron: "17 4 * * 1"
workflow_dispatch:

concurrency:
group: production
cancel-in-progress: false

jobs:
maintain:
name: Update VPS and refresh deployment
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
contents: read
env:
VPS_HOST: ${{ secrets.VPS_HOST }}
VPS_PORT: ${{ secrets.VPS_PORT || '22' }}
VPS_USER: ${{ secrets.VPS_USER }}
VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
VPS_APP_DIR: ${{ secrets.VPS_APP_DIR }}

steps:
- name: Checkout
uses: actions/checkout@v6

- name: Validate maintenance secrets
shell: bash
run: |
missing=0
for name in VPS_HOST VPS_PORT VPS_USER VPS_SSH_KEY VPS_APP_DIR; do
if [ -z "${!name}" ]; then
echo "::error::$name is not set"
missing=1
fi
done

case "$VPS_APP_DIR" in
""|"/"|"/root"|"/home"|"/opt"|"/usr"|"/var")
echo "::error::VPS_APP_DIR must be a dedicated app directory, for example /opt/kairoreader.com"
missing=1
;;
esac

exit "$missing"

- name: Prepare SSH
shell: bash
run: |
install -m 700 -d ~/.ssh
printf '%s\n' "$VPS_SSH_KEY" > ~/.ssh/kairoreader_deploy
chmod 600 ~/.ssh/kairoreader_deploy
ssh-keyscan -p "$VPS_PORT" -H "$VPS_HOST" >> ~/.ssh/known_hosts

- name: Upload maintenance script
shell: bash
run: |
remote="${VPS_USER}@${VPS_HOST}"
scp_opts=(-i ~/.ssh/kairoreader_deploy -P "$VPS_PORT")
scp "${scp_opts[@]}" .cicd/update-vps.sh "$remote:/tmp/kairoreader-update-vps.sh"

- name: Update packages and refresh deployment
shell: bash
run: |
remote="${VPS_USER}@${VPS_HOST}"
ssh_opts=(-i ~/.ssh/kairoreader_deploy -p "$VPS_PORT")

ssh "${ssh_opts[@]}" "$remote" \
"set -eu
chmod 700 /tmp/kairoreader-update-vps.sh
if [ \"\$(id -u)\" -eq 0 ]; then
/tmp/kairoreader-update-vps.sh
elif command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then
sudo /tmp/kairoreader-update-vps.sh
else
echo 'VPS package maintenance requires root or passwordless sudo.' >&2
exit 1
fi
rm -f /tmp/kairoreader-update-vps.sh
cd '$VPS_APP_DIR'
if [ ! -x .cicd/deploy.sh ]; then
echo 'No deployed maintenance-capable release found. Deploy main once first.' >&2
exit 1
fi
APP_DIR='$VPS_APP_DIR' ./.cicd/deploy.sh"
1 change: 1 addition & 0 deletions .npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
strict-allow-scripts=true
3 changes: 2 additions & 1 deletion Containerfile → Dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
FROM node:20-alpine AS build
FROM node:24-alpine AS build
WORKDIR /app
RUN npm install --global npm@12
COPY package*.json ./
RUN npm ci
COPY . .
Expand Down
Loading