name: Publish Pages on: push: branches: [main] workflow_dispatch: jobs: publish: runs-on: ubuntu-latest container: image: node:22-bookworm env: R2_ENDPOINT: https://${{ secrets.F40_PAGES_R2_ACCOUNT_ID }}.r2.cloudflarestorage.com R2_ACCOUNT_ID: ${{ secrets.F40_PAGES_R2_ACCOUNT_ID }} R2_BUCKET: ${{ secrets.F40_PAGES_R2_BUCKET }} SITE_NAME_OVERRIDE: ${{ vars.F40_PAGES_SITE_NAME }} BUILD_COMMAND_OVERRIDE: ${{ vars.F40_PAGES_BUILD_COMMAND }} OUTPUT_DIR_OVERRIDE: ${{ vars.F40_PAGES_OUTPUT_DIR }} DEFAULT_BUILD_COMMAND: ${{ vars.F40_PAGES_DEFAULT_BUILD_COMMAND }} DEFAULT_OUTPUT_DIR: ${{ vars.F40_PAGES_DEFAULT_OUTPUT_DIR }} steps: - uses: actions/checkout@v4 - name: Show runtime versions run: | node --version npm --version - name: Resolve settings run: | SITE_NAME="${SITE_NAME_OVERRIDE:-${GITHUB_REPOSITORY##*/}}" BUILD_COMMAND="${BUILD_COMMAND_OVERRIDE:-${DEFAULT_BUILD_COMMAND:-npm ci && npm run build:test-app}}" OUTPUT_DIR="${OUTPUT_DIR_OVERRIDE:-${DEFAULT_OUTPUT_DIR:-dist}}" echo "SITE_NAME=$SITE_NAME" >> "$GITHUB_ENV" echo "BUILD_COMMAND=$BUILD_COMMAND" >> "$GITHUB_ENV" echo "OUTPUT_DIR=$OUTPUT_DIR" >> "$GITHUB_ENV" - name: Validate publish settings env: AWS_ACCESS_KEY_ID: ${{ secrets.F40_PAGES_R2_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.F40_PAGES_R2_SECRET_ACCESS_KEY }} run: | missing=0 for name in R2_ACCOUNT_ID R2_BUCKET AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY; do value="$(eval "printf '%s' \"\${$name:-}\"")" if [ -z "$value" ]; then echo "::error::$name is required" missing=1 fi done if [ "$missing" -ne 0 ]; then exit 1 fi case "$SITE_NAME" in ""|*[!A-Za-z0-9._-]*) echo "::error::SITE_NAME must contain only letters, numbers, '.', '_', and '-'" exit 1 ;; esac - name: Install R2 publisher run: | PUBLISHER_DIR="${RUNNER_TEMP:-/tmp}/f40-pages-publisher" mkdir -p "$PUBLISHER_DIR" npm install --prefix "$PUBLISHER_DIR" --no-audit --no-fund @aws-sdk/client-s3 echo "PUBLISHER_DIR=$PUBLISHER_DIR" >> "$GITHUB_ENV" - name: Build static site run: sh -c "$BUILD_COMMAND" - name: Validate build output run: | if [ ! -d "$OUTPUT_DIR" ]; then echo "::error::Build output directory '$OUTPUT_DIR' does not exist" exit 1 fi if [ ! -f "$OUTPUT_DIR/index.html" ]; then echo "::warning::'$OUTPUT_DIR/index.html' does not exist; /$SITE_NAME/ will return 404" fi - name: Publish to R2 env: AWS_ACCESS_KEY_ID: ${{ secrets.F40_PAGES_R2_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.F40_PAGES_R2_SECRET_ACCESS_KEY }} run: | RUN_ATTEMPT="${GITHUB_RUN_ATTEMPT:-1}" RELEASE="${GITHUB_SHA}-${RUN_ATTEMPT}" PREFIX="sites/${SITE_NAME}/releases/${RELEASE}" export RELEASE PREFIX cat > "$PUBLISHER_DIR/publish.mjs" <<'EOF' import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; import { createReadStream } from "node:fs"; import { readdir, stat, writeFile } from "node:fs/promises"; import { join, relative, sep } from "node:path"; const required = [ "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "OUTPUT_DIR", "PREFIX", "R2_BUCKET", "R2_ENDPOINT", "RELEASE", "GITHUB_SHA", "SITE_NAME" ]; for (const name of required) { if (!process.env[name]) { throw new Error(`${name} is required`); } } const client = new S3Client({ endpoint: process.env.R2_ENDPOINT, forcePathStyle: true, region: "auto", credentials: { accessKeyId: process.env.AWS_ACCESS_KEY_ID, secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY } }); const contentTypes = new Map([ [".avif", "image/avif"], [".css", "text/css; charset=utf-8"], [".gif", "image/gif"], [".html", "text/html; charset=utf-8"], [".ico", "image/x-icon"], [".jpeg", "image/jpeg"], [".jpg", "image/jpeg"], [".js", "text/javascript; charset=utf-8"], [".json", "application/json; charset=utf-8"], [".m4v", "video/mp4"], [".mjs", "text/javascript; charset=utf-8"], [".mov", "video/quicktime"], [".mp4", "video/mp4"], [".png", "image/png"], [".svg", "image/svg+xml"], [".txt", "text/plain; charset=utf-8"], [".wasm", "application/wasm"], [".webm", "video/webm"], [".webp", "image/webp"], [".xml", "application/xml; charset=utf-8"] ]); async function walk(dir) { const entries = await readdir(dir, { withFileTypes: true }); const files = []; for (const entry of entries) { const path = join(dir, entry.name); if (entry.isDirectory()) { files.push(...(await walk(path))); } else if (entry.isFile()) { files.push(path); } } return files; } function contentTypeFor(path) { const lower = path.toLowerCase(); const index = lower.lastIndexOf("."); if (index === -1) { return "application/octet-stream"; } return contentTypes.get(lower.slice(index)) ?? "application/octet-stream"; } const outputDir = process.env.OUTPUT_DIR; const files = await walk(outputDir); for (const file of files) { const relativePath = relative(outputDir, file).split(sep).join("/"); const key = `${process.env.PREFIX}/${relativePath}`; const metadata = await stat(file); await client.send( new PutObjectCommand({ Bucket: process.env.R2_BUCKET, Key: key, Body: createReadStream(file), ContentLength: metadata.size, ContentType: contentTypeFor(file), CacheControl: "public,max-age=31536000,immutable" }) ); console.log(`uploaded ${key}`); } const current = { site: process.env.SITE_NAME, release: process.env.RELEASE, sha: process.env.GITHUB_SHA, publishedAt: new Date().toISOString() }; const currentPath = join(process.cwd(), "current.json"); await writeFile(currentPath, `${JSON.stringify(current)}\n`); await client.send( new PutObjectCommand({ Bucket: process.env.R2_BUCKET, Key: `sites/${process.env.SITE_NAME}/current.json`, Body: createReadStream(currentPath), ContentType: "application/json; charset=utf-8", CacheControl: "no-store" }) ); console.log(`published ${process.env.SITE_NAME} release ${process.env.RELEASE}`); EOF node "$PUBLISHER_DIR/publish.mjs"