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
47 changes: 47 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
name: CI

on:
pull_request:
push:
branches: [main]

permissions:
contents: read

concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false

- name: Set up Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.2.19

- name: Install Hugo
uses: peaceiris/actions-hugo@2752ce1d29631191ea3f27c23495fa06139a5b78 # v3
with:
hugo-version: 0.143.0
extended: true

- name: Build site
run: bash scripts/build-site.sh

- name: Validate generated site and slides
run: bash scripts/validate-content.sh

- name: Upload generated site
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: public-${{ github.sha }}
path: public
if-no-files-found: error
retention-days: 7
80 changes: 80 additions & 0 deletions .github/workflows/firebase-hosting-main.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
name: Deploy to Firebase Hosting

on:
push:
branches: [main]
workflow_dispatch:
inputs:
ref:
description: Full 40-character commit SHA to deploy
required: true
type: string

permissions:
contents: read

concurrency:
group: production-deployment
cancel-in-progress: false

jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Validate rollback ref
if: github.event_name == 'workflow_dispatch'
env:
DEPLOY_REF: ${{ inputs.ref }}
run: |
[[ "${DEPLOY_REF}" =~ ^[0-9a-f]{40}$ ]]

- name: Check out repository
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
ref: ${{ inputs.ref || github.sha }}
persist-credentials: false

- name: Set up Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.2.19

- name: Install Hugo
uses: peaceiris/actions-hugo@2752ce1d29631191ea3f27c23495fa06139a5b78 # v3
with:
hugo-version: 0.143.0
extended: true

- name: Build site
run: bash scripts/build-site.sh

- name: Validate generated site and slides
run: bash scripts/validate-content.sh

- name: Upload generated site
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: public-${{ github.sha }}
path: public
if-no-files-found: error
retention-days: 7

deploy:
needs: build
runs-on: ubuntu-latest
environment: production
steps:
- name: Download generated site
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: public-${{ github.sha }}
path: public

- name: Deploy to Firebase Hosting
uses: FirebaseExtended/action-hosting-deploy@500ac625ca2dd40cbd15f7659af953801858032a # v0
with:
firebaseServiceAccount: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_HERMES_7B876 }}
firebaseToolsVersion: 15.24.0
channelId: live
target: chop-ink
projectId: hermes-7b876
31 changes: 0 additions & 31 deletions .github/workflows/firebase-hosting-tag.yml

This file was deleted.

15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,18 @@ Find me around the web:
```
hugo server -D
```

## Publishing

Open a pull request for every content or code change. CI builds the site and
validates slide content before the pull request can be merged.

Merging to `main` builds the same site again and deploys it to Firebase
Hosting. Production deployment does not use release tags. To redeploy `main`
or roll back to a known commit, run **Deploy to Firebase Hosting** manually
from the GitHub Actions tab and provide its full 40-character commit SHA in
the `ref` field.

The production workflow requires the `FIREBASE_SERVICE_ACCOUNT_HERMES_7B876`
repository secret. Configure `production` as a protected environment and
require the CI check on the `main` branch before merging.
65 changes: 39 additions & 26 deletions scripts/build-deps.sh
Original file line number Diff line number Diff line change
@@ -1,28 +1,41 @@
#!/bin/bash

REVEALJS_DEST=static/revealjs/

wget -q https://github.com/hakimel/reveal.js/archive/master.zip -O revealjs.zip

unzip revealjs.zip

mkdir -p $REVEALJS_DEST
cp -r reveal.js-master/dist $REVEALJS_DEST
cp -r reveal.js-master/plugin $REVEALJS_DEST

ls $REVEALJS_DEST

rm -r reveal.js-master
rm revealjs.zip

BLOWFISH_VERSION=2.87.0

wget -q "https://github.com/nunocoracao/blowfish/archive/refs/tags/v$BLOWFISH_VERSION.zip" -O blowfish.zip

unzip blowfish.zip

#!/usr/bin/env bash

set -euo pipefail

# Keep generated dependencies reproducible. The versions are pinned to the
# following upstream commits rather than downloading a moving branch.
REVEALJS_VERSION="5.2.1"
REVEALJS_COMMIT="25e52e26af09933a98afb24cfdd3574e9055034d"
REVEALJS_SHA256="32e0a766f0d4852fcada7582d9c058d92f931afbb80cbd5ab016008e4d21d40b"
BLOWFISH_VERSION="2.87.0"
BLOWFISH_COMMIT="2f12b8d0d9ee90a4fa5481a92c2032ba97f1f358"
BLOWFISH_SHA256="9604f9cb9efc0a3df14e8f79975c828b64629f7f4cade878866c1468602a9768"

REVEALJS_DEST="static/revealjs"
TEMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TEMP_DIR"' EXIT

curl --fail --silent --show-error --location --retry 3 \
--connect-timeout 10 --max-time 120 \
"https://github.com/hakimel/reveal.js/archive/${REVEALJS_COMMIT}.zip" \
--output "$TEMP_DIR/revealjs.zip"
echo "${REVEALJS_SHA256} ${TEMP_DIR}/revealjs.zip" | shasum -a 256 --check --status
unzip -q "$TEMP_DIR/revealjs.zip" -d "$TEMP_DIR/revealjs"

rm -rf "$REVEALJS_DEST"
mkdir -p "$REVEALJS_DEST"
cp -R "$TEMP_DIR/revealjs/reveal.js-${REVEALJS_COMMIT}/dist" "$REVEALJS_DEST/"
cp -R "$TEMP_DIR/revealjs/reveal.js-${REVEALJS_COMMIT}/plugin" "$REVEALJS_DEST/"

curl --fail --silent --show-error --location --retry 3 \
--connect-timeout 10 --max-time 120 \
"https://github.com/nunocoracao/blowfish/archive/${BLOWFISH_COMMIT}.zip" \
--output "$TEMP_DIR/blowfish.zip"
echo "${BLOWFISH_SHA256} ${TEMP_DIR}/blowfish.zip" | shasum -a 256 --check --status
unzip -q "$TEMP_DIR/blowfish.zip" -d "$TEMP_DIR/blowfish"

rm -rf themes/blowfish
mkdir -p themes
mv blowfish-* themes/blowfish
rm blowfish.zip
mv "$TEMP_DIR/blowfish/blowfish-${BLOWFISH_COMMIT}" themes/blowfish

ls themes
printf 'Prepared reveal.js %s and Blowfish %s\n' "$REVEALJS_VERSION" "$BLOWFISH_VERSION"
6 changes: 6 additions & 0 deletions scripts/build-site.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
#!/usr/bin/env bash

set -euo pipefail

bash scripts/build-deps.sh
hugo --gc --cleanDestinationDir --minify --panicOnWarning --logLevel info
13 changes: 13 additions & 0 deletions scripts/validate-content.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
#!/usr/bin/env bash

set -euo pipefail

for directory in content/slides/*/; do
bun run scripts/validate-slides.ts "$directory"
done

test -f public/index.html
test -f public/index.xml
test -f public/sitemap.xml
test -f public/robots.txt
bun run scripts/validate-links.ts
61 changes: 61 additions & 0 deletions scripts/validate-links.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import {readdir, readFile} from 'node:fs/promises';
import {dirname, join, posix, relative, resolve} from 'node:path';

const root = resolve('public');
const htmlFiles: string[] = [];
const errors: string[] = [];

async function collect(directory: string): Promise<void> {
for (const entry of await readdir(directory, {withFileTypes: true})) {
const path = join(directory, entry.name);
if (entry.isDirectory()) await collect(path);
else if (entry.name.endsWith('.html')) htmlFiles.push(path);
}
}

function isExternal(reference: string): boolean {
return reference.startsWith('#') ||
/^(?:[a-z][a-z\d+.-]*:|\/\/)/i.test(reference);
}

function candidates(reference: string, source: string): string[] {
const clean = reference.split(/[?#]/, 1)[0];
const target = clean.startsWith('/')
? clean.slice(1)
: posix.normalize(join(dirname(relative(root, source)), clean));
const normalized = target.replace(/^\.\//, '');

if (normalized.endsWith('/')) return [join(root, normalized, 'index.html')];
return [
join(root, normalized),
join(root, normalized, 'index.html'),
join(root, `${normalized}.html`),
];
}

await collect(root);

for (const source of htmlFiles) {
const html = await readFile(source, 'utf8');
for (const match of html.matchAll(/(?:href|src)=["']([^"']+)["']/gi)) {
const reference = match[1];
if (isExternal(reference)) continue;
if (!candidates(reference, source).some((path) => {
try {
return Bun.file(path).size > 0;
} catch {
return false;
}
})) {
errors.push(`${relative(root, source)} -> ${reference}`);
}
}
}

if (errors.length > 0) {
console.error(`Generated-site link validation failed (${errors.length} broken reference(s))`);
for (const error of errors) console.error(`- ${error}`);
process.exit(1);
}

console.log(`Generated-site link validation passed (${htmlFiles.length} HTML files)`);
8 changes: 6 additions & 2 deletions scripts/validate-slides.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,12 @@ if (slides.length === 0) {
}

const promptCount = (index.match(/\*\*AI Image Prompt:\*\*/g) ?? []).length;
if (promptCount !== slides.length) {
errors.push(`expected one AI Image Prompt per slide, found ${promptCount} for ${slides.length} slides`);
if (promptCount > slides.length) {
errors.push(`found more AI Image Prompts than slides: ${promptCount} for ${slides.length} slides`);
} else if (promptCount === 0) {
warnings.push('no AI Image Prompt metadata found; legacy decks are allowed');
} else if (promptCount < slides.length) {
warnings.push(`AI Image Prompt metadata is partial: ${promptCount} for ${slides.length} slides`);
}

const imageReferences = [
Expand Down
Loading