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
3 changes: 2 additions & 1 deletion .gitattributes
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
*.pldb linguist-language=pldb
*.parsers linguist-language=Scroll
*.scroll linguist-language=Scroll eol=lf
*.scroll linguist-language=Scroll eol=lf
*.sh text eol=lf
33 changes: 33 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ jobs:
- name: Install dependencies
run: npm install

- name: Check deployment scripts
run: |
bash -n setup-pldb.sh
bash -n code/deploy-release.sh
python3 code/test_deploy_release.py

- name: Build site
run: node --max-old-space-size=4096 build.js

Expand All @@ -54,3 +60,30 @@ jobs:
run: gh release create latest /tmp/site.tar.gz --title "Latest Build" --notes "Auto-built from ${{ github.sha }}"
env:
GH_TOKEN: ${{ github.token }}

- name: Deploy published build to production
if: github.repository == 'Programming-Language-DataBase/pldb' && (github.event_name == 'push' || github.event_name == 'schedule')
timeout-minutes: 20
env:
DEPLOY_HOST: ${{ secrets.PLDB_DEPLOY_HOST }}
DEPLOY_KEY: ${{ secrets.PLDB_DEPLOY_KEY }}
DEPLOY_KNOWN_HOSTS: ${{ secrets.PLDB_DEPLOY_KNOWN_HOSTS }}
shell: bash
run: |
set -euo pipefail
: "${DEPLOY_HOST:?Set PLDB_DEPLOY_HOST to the production server hostname or IP}"
: "${DEPLOY_KEY:?Set PLDB_DEPLOY_KEY to the root SSH private key}"
: "${DEPLOY_KNOWN_HOSTS:?Set PLDB_DEPLOY_KNOWN_HOSTS to the verified server SSH host key entry}"
ssh_dir=$(mktemp -d)
trap 'rm -rf "$ssh_dir"' EXIT
printf '%s\n' "$DEPLOY_KEY" > "$ssh_dir/key"
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > "$ssh_dir/known_hosts"
chmod 600 "$ssh_dir/key" "$ssh_dir/known_hosts"
ssh_options=(-i "$ssh_dir/key" -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o "UserKnownHostsFile=$ssh_dir/known_hosts" -o ConnectTimeout=30)
artifact="/tmp/pldb-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}.tar.gz"
scp "${ssh_options[@]}" /tmp/site.tar.gz "root@${DEPLOY_HOST}:$artifact"
# Deploy this run's exact artifact, even if the latest release changes.
status=0
ssh "${ssh_options[@]}" "root@${DEPLOY_HOST}" "bash -s -- '$artifact'" < code/deploy-release.sh || status=$?
ssh "${ssh_options[@]}" "root@${DEPLOY_HOST}" "rm -f -- '$artifact'"
exit "$status"
3 changes: 2 additions & 1 deletion build.js
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,8 @@ async function build() {
runQuiet(`node "${SCROLL_CLI}" build`, folderPath)
console.log(` ✅ ${dir}/ built successfully`)
} catch (err) {
console.error(` ⚠️ ${dir}/ build had errors (continuing...)`)
console.error(` ❌ ${dir}/ build failed; refusing to publish an incomplete site`)
throw err
}
}
}
Expand Down
63 changes: 63 additions & 0 deletions code/deploy-release.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
#!/bin/bash
# Deploy a supplied build artifact, or download latest for the weekly fallback.
set -euo pipefail

exec 9>/root/pldb-deploy.lock
flock -w 600 9

INSTALL_DIR=/root/pldb
STAGING=$(mktemp -d /root/pldb-staging.XXXXXX)
BACKUP="$STAGING/previous"
SWAPPED=false
SUCCESS=false

cleanup() {
local status=$?
trap - EXIT
if [ "$SWAPPED" = true ] && [ "$SUCCESS" = false ]; then
echo 'Deployment failed; restoring the previous site.' >&2
rm -rf "$INSTALL_DIR"
if ! mv "$BACKUP" "$INSTALL_DIR"; then
echo "Restore failed. Previous site retained at $BACKUP" >&2
exit 1
fi
systemctl restart pldb || true
fi
rm -rf "$STAGING"
exit "$status"
}
trap cleanup EXIT

if [ "$#" -gt 0 ]; then
cp -- "$1" "$STAGING/site.tar.gz"
else
REPO_BASE=$(cat /root/pldb-repo.conf)
curl --fail --show-error --location --retry 3 \
"$REPO_BASE/releases/download/latest/site.tar.gz" -o "$STAGING/site.tar.gz"
fi

mkdir "$STAGING/site"
tar xzf "$STAGING/site.tar.gz" -C "$STAGING/site"
test -s "$STAGING/site/index.html"
test -s "$STAGING/site/package.json"
cd "$STAGING/site"
npm install --quiet
cd /root

# Preserve the running installation until the replacement is ready.
mv "$INSTALL_DIR" "$BACKUP"
SWAPPED=true
mv "$STAGING/site" "$INSTALL_DIR"
systemctl restart pldb

for attempt in {1..12}; do
if systemctl is-active --quiet pldb && \
curl --fail --silent --max-time 5 http://localhost:80/ -o /dev/null; then
SUCCESS=true
echo 'PLDB deployment healthy.'
exit 0
fi
sleep 5
done
echo 'PLDB failed its HTTP health check.' >&2
exit 1
103 changes: 103 additions & 0 deletions code/test_deploy_release.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""Exercise deployment against a temporary filesystem and mocked services.

Run: python code/test_deploy_release.py (requires Bash).
"""
import io
import os
from pathlib import Path
import shutil
import subprocess
import tarfile
import tempfile
import unittest


def shell_path(path):
value = Path(path).resolve().as_posix()
if os.name == "nt":
return "/" + value[0].lower() + value[2:]
return value


class DeploymentTests(unittest.TestCase):
def deploy(self, scenario="success", download=False):
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
site = root / "pldb"
site.mkdir()
(site / "index.html").write_text("old")
(root / "pldb-repo.conf").write_text("https://example.invalid/pldb")
artifact = root / "artifact.tar.gz"
with tarfile.open(artifact, "w:gz") as archive:
files = {"index.html": b"new", "package.json": b"{}"}
if scenario == "invalid":
del files["index.html"]
for name, data in files.items():
info = tarfile.TarInfo(name)
info.size = len(data)
archive.addfile(info, io.BytesIO(data))
script = root / "deploy.sh"
source = Path(__file__).with_name("deploy-release.sh").read_text()
script.write_text(source.replace("/root", shell_path(root)), newline="\n")
mocks = root / "bin"
mocks.mkdir()
commands = {
"flock": 'exit 0',
"sleep": 'exit 0',
"npm": '[ "$SCENARIO" != dependencies ]',
"systemctl": '''if [ "$SCENARIO" = restart ] && [ "$1" = restart ] &&
[ "$(cat "$TEST_ROOT/pldb/index.html")" = new ]; then exit 1; fi
exit 0''',
"curl": '''if [ "$1" = --fail ] && [ "$2" = --show-error ]; then
[ "$SCENARIO" != download ] || exit 1
for last; do :; done
cp "$ARTIFACT" "$last"
else
[ "$SCENARIO" != health ]
fi''',
}
for name, body in commands.items():
target = mocks / name
target.write_text("#!/bin/bash\n" + body + "\n", newline="\n")
target.chmod(0o755)
bash = os.environ.get("TEST_BASH") or shutil.which("bash")
if os.name == "nt" and Path("C:/Program Files/Git/bin/bash.exe").exists():
bash = "C:/Program Files/Git/bin/bash.exe"
self.assertIsNotNone(bash, "Bash is required")
env = dict(os.environ, SCENARIO=scenario, TEST_ROOT=shell_path(root),
ARTIFACT=shell_path(artifact))
# Prepend mocks inside Bash, whose PATH uses POSIX separators.
command = 'export PATH="$1:$PATH"; shift; exec bash "$@"'
args = [bash, "-c", command, "test", shell_path(mocks), shell_path(script)]
if not download:
args.append(shell_path(artifact))
result = subprocess.run(args, env=env, capture_output=True, text=True, timeout=20)
expected_success = scenario == "success"
self.assertEqual(result.returncode == 0, expected_success, result.stdout + result.stderr)
self.assertEqual((site / "index.html").read_text(), "new" if expected_success else "old")
self.assertEqual(list(root.glob("pldb-staging.*")), [])

def test_success(self):
self.deploy()

def test_weekly_download(self):
self.deploy(download=True)

def test_failed_download_keeps_site(self):
self.deploy("download", download=True)

def test_invalid_artifact_keeps_site(self):
self.deploy("invalid")

def test_dependency_failure_keeps_site(self):
self.deploy("dependencies")

def test_restart_failure_rolls_back(self):
self.deploy("restart")

def test_health_failure_rolls_back(self):
self.deploy("health")


if __name__ == "__main__":
unittest.main()
37 changes: 36 additions & 1 deletion readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,42 @@ journalctl -u pldb -f # View logs

### Redeploying after changes

After pushing new changes to `main`, wait for the GitHub Actions build to complete, then re-run `setup-pldb.sh` to deploy the updated site.
In `Programming-Language-DataBase/pldb`, pushes and merges to `main` and the Monday
06:00 UTC scheduled build publish the `latest` release, then deploy that run's
exact `site.tar.gz` to production over SSH. Pull request builds and forks do not
deploy. Deployment failures fail the workflow; the release remains available.

Configure these Actions secrets in the upstream repository:

| Secret | Value |
|--------|-------|
| `PLDB_DEPLOY_HOST` | Production hostname or IP (SSH port 22) |
| `PLDB_DEPLOY_KEY` | Private key authorized for the server's root user |
| `PLDB_DEPLOY_KNOWN_HOSTS` | Verified OpenSSH known_hosts entry for that hostname/IP |

The server must already have Node.js, npm, curl, tar, flock, and the `pldb`
systemd service installed by `setup-pldb.sh`. Verify the SSH host key through a
trusted channel before saving it. Missing secrets cause the deployment step to
fail with a configuration message.

For an existing server, replace `/root/pldb-update.sh` once with this wrapper
after the first successful automatic deployment, so the weekly fallback uses
the same deployment lock and rollback logic:

```bash
#!/bin/bash
set -euo pipefail
exec bash /root/pldb/code/deploy-release.sh
```

New installations create this wrapper automatically. The weekly cron job runs
Monday at 09:00 in the server's timezone and downloads `latest` from the repository
in `/root/pldb-repo.conf`; set that URL to
`https://github.com/Programming-Language-DataBase/pldb` on the production server.
Both update paths stage dependencies before replacing the site, serialize with
`flock`, and restore the previous site if restart or HTTP health checks fail.

For a manual deployment on the server, run `bash /root/pldb-update.sh`.

## 🌐 Mirrors

Expand Down
73 changes: 2 additions & 71 deletions setup-pldb.sh
Original file line number Diff line number Diff line change
Expand Up @@ -214,78 +214,9 @@ echo "$HTTPS_BASE" > /root/pldb-repo.conf
echo ">>> Installing weekly update script..."
cat > /root/pldb-update.sh << 'UPDATEEOF'
#!/bin/bash
# Weekly auto-update script for the PLDB server.
# Downloads the latest release from GitHub and hot-swaps the site directory.
# Called by cron every Monday at 09:00 UTC.

# Use the deployed updater so improvements arrive with each release.
set -euo pipefail

LOG="/root/pldb-update.log"
INSTALL_DIR="/root/pldb"
CONFIG_FILE="/root/pldb-repo.conf"
WORK_DIR="/root/pldb-new"

log() {
echo "[$(date -u '+%Y-%m-%d %H:%M:%S UTC')] $*" >> "$LOG"
}

log "=== Starting PLDB weekly update ==="

if [ ! -f "$CONFIG_FILE" ]; then
log "ERROR: Config file $CONFIG_FILE not found. Run setup-pldb.sh first."
exit 1
fi
REPO_BASE=$(cat "$CONFIG_FILE")
RELEASE_URL="${REPO_BASE}/releases/download/latest/site.tar.gz"
log "Release URL: $RELEASE_URL"

log "Downloading new release..."
curl -fSL "$RELEASE_URL" -o /tmp/site-new.tar.gz
log "Download complete."

log "Extracting to staging directory..."
rm -rf "$WORK_DIR"
mkdir -p "$WORK_DIR"
tar xzf /tmp/site-new.tar.gz -C "$WORK_DIR"
rm -f /tmp/site-new.tar.gz

log "Installing node modules..."
cd "$WORK_DIR"
npm install --quiet

log "Swapping site directory..."
OLD_DIR="/root/pldb-old"
rm -rf "$OLD_DIR"
if ! mv "$INSTALL_DIR" "$OLD_DIR"; then
log "ERROR: Could not back up $INSTALL_DIR to $OLD_DIR. Aborting."
exit 1
fi
mv "$WORK_DIR" "$INSTALL_DIR"

log "Restarting pldb service..."
if ! systemctl restart pldb; then
log "ERROR: Service failed to restart. Rolling back..."
rm -rf "$INSTALL_DIR" 2>/dev/null || true
mv "$OLD_DIR" "$INSTALL_DIR"
systemctl restart pldb || true
log "Rollback complete. Check service logs: journalctl -u pldb -n 50"
exit 1
fi

sleep 5
if systemctl is-active --quiet pldb; then
log "Service is running. Update complete."
rm -rf "$OLD_DIR"
else
log "ERROR: Service not active after restart. Rolling back..."
rm -rf "$INSTALL_DIR" 2>/dev/null || true
mv "$OLD_DIR" "$INSTALL_DIR"
systemctl restart pldb || true
log "Rollback complete. Check service logs: journalctl -u pldb -n 50"
exit 1
fi

log "=== PLDB weekly update finished successfully ==="
exec bash /root/pldb/code/deploy-release.sh
UPDATEEOF
chmod +x /root/pldb-update.sh
echo " Update script installed at /root/pldb-update.sh"
Expand Down
Loading