diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 00000000..ed79c8da --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,119 @@ +name: Deploy to WebDAV (/BLB) + +# Mirror the site content from the repo to the WebDAV directory served at /BLB/. +# Runs on every push/merge to the default branch (main). Only the files that +# actually changed in the pushed range are uploaded (efficient for a ~32k-file +# static tree), using DELETE-then-PUT so the serving layer's cache is busted. +"on": + push: + branches: + - main + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Deploy changed files to WebDAV + env: + WEBDAV_USER: ${{ secrets.WEBDAV_USER }} + WEBDAV_PASS: ${{ secrets.WEBDAV_PASS }} + WEBDAV_BASE: https://files.thecookiejar.me/www/static/BLB + GITHUB_BEFORE: ${{ github.event.before }} + GITHUB_SHA: ${{ github.sha }} + run: | + if [ -z "$WEBDAV_USER" ] || [ -z "$WEBDAV_PASS" ]; then + echo "::error::WEBDAV_USER / WEBDAV_PASS secrets are not set on the exlim org." + exit 1 + fi + node - <<'NODE' + const { execSync } = require('child_process'); + const fs = require('fs'); + + const USER = process.env.WEBDAV_USER; + const PASS = process.env.WEBDAV_PASS; + const BASE = process.env.WEBDAV_BASE; + const BEFORE = process.env.GITHUB_BEFORE || ''; + const SHA = process.env.GITHUB_SHA || ''; + const vacant = '0'.repeat(40); + + // Simple content-type map (node:20 image has no `mime` package). + const MIME = { + html: 'text/html', htm: 'text/html', js: 'application/javascript', + css: 'text/css', json: 'application/json', txt: 'text/plain', + md: 'text/markdown', xml: 'application/xml', gif: 'image/gif', + jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', + ico: 'image/x-icon', svg: 'image/svg+xml', pdf: 'application/pdf', + mid: 'audio/midi', mp3: 'audio/mpeg', wav: 'audio/x-wav', rm: 'application/vnd.rn-realmedia', + swf: 'application/x-shockwave-flash', jsx: 'text/javascript', mjs: 'application/javascript', + }; + const typeFor = (f) => { + const e = f.split('.').pop().toLowerCase(); + return MIME[e] || 'application/octet-stream'; + }; + + // Which files to deploy? On a normal push, only those changed in the + // pushed range. On the very first deploy (empty before), every file. + let files; + if (BEFORE && BEFORE !== vacant) { + files = execSync(`git diff-tree -r --name-only -z "${BEFORE}" "${SHA}"`, { encoding: 'utf8' }) + .split('\0').filter(Boolean); + } else { + files = execSync('git ls-files -z', { encoding: 'utf8' }) + .split('\0').filter(Boolean); + } + // Never treat CI/config files as site content. + files = files.filter(f => !f.startsWith('.gitea') && !f.startsWith('.github')); + + if (files.length === 0) { + console.log('No site files changed; nothing to deploy.'); + process.exit(0); + } + console.log(`Deploying ${files.length} changed file(s):`); + for (const f of files.slice(0, 20)) console.log(' ' + f); + if (files.length > 20) console.log(` ... and ${files.length - 20} more`); + + const auth = 'Basic ' + Buffer.from(`${USER}:${PASS}`).toString('base64'); + const enc = (p) => p.split('/').map(encodeURIComponent).join('/'); + const urlFor = (f) => BASE + '/' + enc(f); + + async function delThenPut(f) { + const url = urlFor(f); + const body = fs.readFileSync(f); + // DELETE first: serving layer caches PUTs, so overwrite without + // delete silently keeps serving the OLD copy. + try { + await fetch(url, { method: 'DELETE', headers: { Authorization: auth } }); + } catch (e) { /* file may not exist yet -- fine */ } + const r = await fetch(url, { + method: 'PUT', + headers: { Authorization: auth, 'Content-Type': typeFor(f) }, + body, + }); + if (!r.ok) throw new Error(`PUT ${url} -> ${r.status} ${r.statusText}`); + } + + // Small worker pool so large first deploys don't crush the server. + const N = 8; + let i = 0; + const failures = []; + const worker = async () => { + while (i < files.length) { + const f = files[i++]; + try { + await delThenPut(f); + } catch (e) { + failures.push(`${f}: ${e.message}`); + console.log(`::warning::failed ${f} -- ${e.message}`); + } + } + }; + await Promise.all(Array.from({ length: N }, worker)); + if (failures.length) { + console.log(`::error::${failures.length}/${files.length} files failed to deploy`); + for (const f of failures.slice(0, 20)) console.log(' ' + f); + process.exit(1); + } + console.log(`Deployed ${files.length} file(s) successfully.`); + NODE \ No newline at end of file