diff --git a/.github/workflows/sync-addons.yml b/.github/workflows/sync-addons.yml deleted file mode 100644 index 52428a3..0000000 --- a/.github/workflows/sync-addons.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: Sync addons - -# Mirrors the latest PostGuard addon releases (Thunderbird .xpi, Outlook -# manifest.xml) into static/downloads/ so the website can serve them directly. -# Opens a PR when an upstream sha256 differs from what is currently committed; -# no-op otherwise. - -on: - schedule: - - cron: '0 6 * * *' - workflow_dispatch: - -permissions: - contents: write - pull-requests: write - -jobs: - sync: - name: Sync addons - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 - with: - node-version: 24 - - name: Run sync script - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: node scripts/sync-addons.mjs - - name: Open PR if changed - uses: peter-evans/create-pull-request@v7 - with: - token: ${{ secrets.GITHUB_TOKEN }} - branch: chore/sync-addons - delete-branch: true - commit-message: 'chore: sync PostGuard addons to latest releases' - title: 'chore: sync PostGuard addons to latest releases' - body: | - Automated sync of the latest PostGuard addon releases: - - - [`postguard-tb-addon`](https://github.com/encryption4all/postguard-tb-addon/releases/latest) - - [`postguard-outlook-addon`](https://github.com/encryption4all/postguard-outlook-addon/releases/latest) - - Version metadata lives in `static/downloads/*.json`. - add-paths: | - static/downloads/postguard-tb-addon.xpi - static/downloads/postguard-tb-addon.json - static/downloads/postguard-outlook-manifest.xml - static/downloads/postguard-outlook-manifest.json diff --git a/docker/Dockerfile b/docker/Dockerfile index 8269a31..b653ea8 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -74,6 +74,15 @@ COPY docker/default.conf.template /etc/nginx/templates/default.conf.template COPY docker/entrypoint.sh /docker-entrypoint.d/40-substitute-env-vars.sh RUN chmod +x /docker-entrypoint.d/40-substitute-env-vars.sh +# Bundle the addon-sync script + a Node binary so the running container can +# self-refresh PostGuard addon downloads (see /docker-entrypoint.d/50-…). +# We pull the binary from node:24-slim — same debian-bookworm base as nginx, +# so the runtime libs match without an apt install. +COPY --from=node:24-slim /usr/local/bin/node /usr/local/bin/node +COPY scripts/sync-addons.mjs /opt/sync-addons/sync-addons.mjs +COPY docker/sync-addons-loop.sh /docker-entrypoint.d/50-sync-addons.sh +RUN chmod +x /docker-entrypoint.d/50-sync-addons.sh + # Copy frontend built in Stage 1 COPY --from=frontend /app/build /usr/share/nginx/html/postguard diff --git a/docker/sync-addons-loop.sh b/docker/sync-addons-loop.sh new file mode 100644 index 0000000..dd1a73c --- /dev/null +++ b/docker/sync-addons-loop.sh @@ -0,0 +1,39 @@ +#!/bin/sh +# Periodically refresh /downloads/* with the latest published PostGuard addon +# release artifacts. Runs in the background of the nginx container so the +# image never has to be rebuilt for an addon-only release. +# +# The first iteration runs concurrently with nginx start — until it finishes, +# requests are served from whatever was baked into the image at build time. +# scripts/sync-addons.mjs writes via a temp-file + rename, so live downloads +# never observe a partially-written file. +# +# Tunables (env): +# SYNC_ADDONS_INTERVAL seconds between refreshes (default 21600 = 6h) +# SYNC_ADDONS_DISABLE set to any non-empty value to skip the loop entirely +set -eu + +DOWNLOADS_DIR="/usr/share/nginx/html/postguard/downloads" +SCRIPT="/opt/sync-addons/sync-addons.mjs" +INTERVAL="${SYNC_ADDONS_INTERVAL:-21600}" + +if [ -n "${SYNC_ADDONS_DISABLE:-}" ]; then + echo "[sync-addons] disabled via SYNC_ADDONS_DISABLE" + exit 0 +fi + +if [ ! -d "${DOWNLOADS_DIR}" ]; then + echo "[sync-addons] ${DOWNLOADS_DIR} does not exist; skipping" >&2 + exit 0 +fi + +( + while true; do + if ! DOWNLOADS_DIR="${DOWNLOADS_DIR}" node "${SCRIPT}"; then + echo "[sync-addons] sync iteration failed; will retry after ${INTERVAL}s" >&2 + fi + sleep "${INTERVAL}" + done +) & + +echo "[sync-addons] background refresh loop started (interval ${INTERVAL}s, dir ${DOWNLOADS_DIR})" diff --git a/scripts/sync-addons.mjs b/scripts/sync-addons.mjs index d815d3d..1bbb04c 100644 --- a/scripts/sync-addons.mjs +++ b/scripts/sync-addons.mjs @@ -10,12 +10,16 @@ // 5. Otherwise download, verify the sha256 matches, and write the asset + metadata. import { createHash } from 'node:crypto' -import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') -const downloadsDir = resolve(projectRoot, 'static/downloads') +// DOWNLOADS_DIR lets the container point at the live nginx htdocs dir at +// runtime instead of the in-repo static/ tree. +const downloadsDir = process.env.DOWNLOADS_DIR + ? resolve(process.env.DOWNLOADS_DIR) + : resolve(projectRoot, 'static/downloads') const TARGETS = [ { @@ -34,16 +38,17 @@ const TARGETS = [ }, ] +// At the 6h container interval we make 2 unauthenticated calls / 6h — far under +// GitHub's 60/h unauthenticated cap. No token plumbing needed; downloads from +// browser_download_url aren't API-rate-limited at all. async function fetchJson(url) { - const headers = { - Accept: 'application/vnd.github+json', - 'X-GitHub-Api-Version': '2022-11-28', - 'User-Agent': 'postguard-website-sync', - } - if (process.env.GITHUB_TOKEN) { - headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}` - } - const res = await fetch(url, { headers }) + const res = await fetch(url, { + headers: { + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': 'postguard-website-sync', + }, + }) if (!res.ok) { throw new Error(`GET ${url} failed: ${res.status} ${res.statusText}`) } @@ -97,6 +102,34 @@ async function findReleaseWithAsset(target) { ) } +// writeAtomic writes to .tmp and then renames into place so concurrent +// readers (e.g. nginx serving live download requests) never observe a +// partially-written file. POSIX rename(2) is atomic within a single filesystem. +async function writeAtomic(path, data) { + const tmp = `${path}.tmp` + await writeFile(tmp, data) + await rename(tmp, path) +} + +async function writeMeta(metaPath, { release, asset, sha256, size }) { + await writeAtomic( + metaPath, + JSON.stringify( + { + tag: release.tag_name, + assetName: asset.name, + sha256, + size, + publishedAt: release.published_at, + sourceUrl: asset.browser_download_url, + releaseUrl: release.html_url, + }, + null, + 2 + ) + '\n' + ) +} + async function syncTarget(target) { const outputPath = resolve(downloadsDir, target.outputFile) const metaPath = resolve(downloadsDir, target.metaFile) @@ -111,9 +144,25 @@ async function syncTarget(target) { const cached = await readCached(metaPath) if (cached?.sha256 === remoteSha) { + if (cached.tag === release.tag_name) { + console.log( + `[${target.name}] up-to-date: ${release.tag_name} (sha256 ${remoteSha})` + ) + return + } + // Upstream re-tagged with byte-identical content (e.g. v0.1.3 → v0.1.5 + // with the same manifest.xml). Refresh the metadata so tag/releaseUrl/ + // publishedAt stay current without a redundant re-download. console.log( - `[${target.name}] up-to-date: ${cached.tag} (sha256 ${remoteSha})` + `[${target.name}] re-tagged: ${cached.tag} -> ${release.tag_name} (content unchanged)` ) + await writeMeta(metaPath, { + release, + asset, + sha256: remoteSha, + size: asset.size ?? cached.size, + }) + console.log(`[${target.name}] wrote ${metaPath}`) return } console.log( @@ -139,23 +188,13 @@ async function syncTarget(target) { } await mkdir(downloadsDir, { recursive: true }) - await writeFile(outputPath, buf) - await writeFile( - metaPath, - JSON.stringify( - { - tag: release.tag_name, - assetName: asset.name, - sha256: localSha, - size: buf.length, - publishedAt: release.published_at, - sourceUrl: asset.browser_download_url, - releaseUrl: release.html_url, - }, - null, - 2 - ) + '\n' - ) + await writeAtomic(outputPath, buf) + await writeMeta(metaPath, { + release, + asset, + sha256: localSha, + size: buf.length, + }) console.log(`[${target.name}] wrote ${outputPath} (${buf.length} bytes)`) console.log(`[${target.name}] wrote ${metaPath}`) } diff --git a/static/downloads/postguard-outlook-manifest.json b/static/downloads/postguard-outlook-manifest.json index 37f6300..029dc4a 100644 --- a/static/downloads/postguard-outlook-manifest.json +++ b/static/downloads/postguard-outlook-manifest.json @@ -1,9 +1,9 @@ { - "tag": "v0.1.3", + "tag": "v0.1.5", "assetName": "manifest.xml", "sha256": "4646512fab112bd109c8ee56457c7c9676a361f2a36185b78288a4187daaf434", "size": 10357, - "publishedAt": "2026-05-06T16:02:32Z", - "sourceUrl": "https://github.com/encryption4all/postguard-outlook-addon/releases/download/v0.1.3/manifest.xml", - "releaseUrl": "https://github.com/encryption4all/postguard-outlook-addon/releases/tag/v0.1.3" + "publishedAt": "2026-05-07T15:05:29Z", + "sourceUrl": "https://github.com/encryption4all/postguard-outlook-addon/releases/download/v0.1.5/manifest.xml", + "releaseUrl": "https://github.com/encryption4all/postguard-outlook-addon/releases/tag/v0.1.5" }