Add authenticated photo uploads and GitHub Actions publishing

This commit is contained in:
2026-09-19 14:16:13 +03:30
parent 0e7c825215
commit 25c82662b7
17 changed files with 873 additions and 12 deletions
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
set -euo pipefail
: "${DEPLOY_HOST:?Missing DEPLOY_HOST}"
: "${DEPLOY_USER:?Missing DEPLOY_USER}"
: "${DEPLOY_PATH:?Missing DEPLOY_PATH}"
: "${DEPLOY_SSH_KEY:?Configure the DEPLOY_SSH_KEY Actions secret}"
: "${DEPLOY_KNOWN_HOSTS:?Configure the DEPLOY_KNOWN_HOSTS Actions secret}"
: "${GITHUB_SHA:?Missing build commit}"
: "${GITHUB_RUN_NUMBER:?Missing run number}"
: "${GITHUB_RUN_ATTEMPT:?Missing run attempt}"
DEPLOY_PORT=${DEPLOY_PORT:-22}
# These values cross an SSH shell boundary. Reject shell syntax.
[[ $DEPLOY_HOST =~ ^[a-zA-Z0-9][a-zA-Z0-9.-]*$ ]]
[[ $DEPLOY_USER =~ ^[a-zA-Z0-9_][a-zA-Z0-9_-]*$ ]]
[[ $DEPLOY_PATH =~ ^/[a-zA-Z0-9_/-]+$ && $DEPLOY_PATH != / ]]
[[ $DEPLOY_PORT =~ ^[0-9]+$ && $GITHUB_SHA =~ ^[a-f0-9]{40}$ ]]
[[ $GITHUB_RUN_NUMBER =~ ^[0-9]+$ && $GITHUB_RUN_ATTEMPT =~ ^[0-9]+$ ]]
test -f _site/art/index.html
test -f _site/photo-publication.json
credentials=$(mktemp -d)
trap 'rm -rf "$credentials"' EXIT
chmod 700 "$credentials"
printf '%s\n' "$DEPLOY_SSH_KEY" > "$credentials/key"
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > "$credentials/known_hosts"
chmod 600 "$credentials/key" "$credentials/known_hosts"
target="$DEPLOY_USER@$DEPLOY_HOST"
release="$DEPLOY_PATH/releases/$GITHUB_RUN_NUMBER-$GITHUB_RUN_ATTEMPT-$GITHUB_SHA"
ssh_options=(-i "$credentials/key" -p "$DEPLOY_PORT" -o BatchMode=yes
-o IdentitiesOnly=yes -o StrictHostKeyChecking=yes
-o "UserKnownHostsFile=$credentials/known_hosts")
ssh "${ssh_options[@]}" "$target" "mkdir -p '$release'"
# Checksums and hard links avoid retransmitting existing photos on every build.
printf -v transport 'ssh -i %q -p %q -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=%q' \
"$credentials/key" "$DEPLOY_PORT" "$credentials/known_hosts"
rsync -azc --delete --link-dest="$DEPLOY_PATH/current/" -e "$transport" _site/ "$target:$release/"
ssh "${ssh_options[@]}" "$target" "bash -s -- '$DEPLOY_PATH' '$release' '$GITHUB_RUN_NUMBER'" <<'REMOTE'
set -euo pipefail
destination=$1
release=$2
run=$3
exec 9>"$destination/deploy.lock"
flock 9
test -f "$release/art/index.html"
test -f "$release/photo-publication.json"
previous=0
if [ -f "$destination/current/deployment-run" ]; then
previous=$(cat "$destination/current/deployment-run")
fi
if (( run < previous )); then
echo "A newer workflow has already deployed; leaving the current site intact."
exit 0
fi
printf '%s\n' "$run" > "$release/deployment-run"
chmod -R a+rX "$release"
temporary="$destination/.current-$run-$$"
ln -s "releases/$(basename "$release")" "$temporary"
mv -Tf "$temporary" "$destination/current"
echo "Deployed $release"
REMOTE
+10
View File
@@ -0,0 +1,10 @@
"""Receipt of the photos actually rendered by this build."""
import json
import os
from pathlib import Path
import re
photos = sorted(set(re.findall(r'id="photo-([a-f0-9]{64})"', Path('_site/art/index.html').read_text())))
Path('_site/photo-publication.json').write_text(json.dumps({
'commit': os.environ['GITHUB_SHA'], 'photos': photos,
}) + '\n')
+52
View File
@@ -0,0 +1,52 @@
import json
import os
from pathlib import Path
import platform
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[2]
DEPLOY = ROOT / 'scripts/deploy-site.sh'
class DeploymentTests(unittest.TestCase):
def test_manifest_only_lists_rendered_photos(self):
with tempfile.TemporaryDirectory() as folder:
root = Path(folder)
(root / '_site/art').mkdir(parents=True)
job = 'a' * 64
(root / '_site/art/index.html').write_text(f'<div id="photo-{job}"></div>')
subprocess.run(['python3', str(ROOT / 'scripts/photo-manifest.py')], cwd=root,
env=dict(os.environ, GITHUB_SHA='b' * 40), check=True)
self.assertEqual(json.loads((root / '_site/photo-publication.json').read_text()),
{'commit': 'b' * 40, 'photos': [job]})
@unittest.skipUnless(platform.system() == 'Linux', 'Production activation uses GNU mv and flock')
def test_activation_failure_and_stale_workflow(self):
activation = DEPLOY.read_text().split("<<'REMOTE'\n", 1)[1].rsplit('\nREMOTE', 1)[0]
with tempfile.TemporaryDirectory() as folder:
root = Path(folder)
(root / 'releases').mkdir()
def activate(number, complete=True):
release = root / 'releases' / str(number)
(release / 'art').mkdir(parents=True)
(release / 'art/index.html').write_text(str(number))
if complete:
(release / 'photo-publication.json').write_text('{"photos":[]}')
return subprocess.run(['bash', '-s', '--', str(root), str(release), str(number)],
input=activation, text=True, capture_output=True)
self.assertEqual(activate(2).returncode, 0)
self.assertEqual((root / 'current/art/index.html').read_text(), '2')
self.assertNotEqual(activate(3, complete=False).returncode, 0)
self.assertEqual((root / 'current/art/index.html').read_text(), '2')
self.assertEqual(activate(1).returncode, 0)
self.assertEqual((root / 'current/art/index.html').read_text(), '2')
self.assertEqual(activate(4).returncode, 0)
self.assertEqual((root / 'current/art/index.html').read_text(), '4')
if __name__ == '__main__':
unittest.main()