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/scripts/build-site.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Construit le site statique complet dans _site/ : émet les credentials signés,
# la Bitstring Status List, le flux annuaire, puis assemble tout (clés, preuves,
# pages de vérif, code navigateur, photos). Partagé par les workflows Pages et VPS.
#
# Requiert : SIGNING_PRIVATE_KEY (env), un dépôt checkout avec historique + LFS,
# et les deps des scripts installées (.github/scripts). Ne fabrique PAS le CNAME
# (spécifique à Pages) : le workflow appelant l'ajoute si besoin.
set -euo pipefail
shopt -s nullglob

echo "== Émission des credentials (déterministe) =="
count=0
for d in data/members/*/; do
f="${d}record.yml"
[ -f "$f" ] || continue
certified_on="$(git log --diff-filter=A --format=%aI -- "$f" | tail -1)"
if [ -z "$certified_on" ]; then
echo "::warning::pas de commit d'ajout pour $f, ignoré"; continue
fi
CERTIFIED_ON="$certified_on" node .github/scripts/sign-credential.mjs "$f"
handle="$(basename "$d")"
cp site/badge-page.html "u/$handle/index.html"
count=$((count + 1))
done
echo "émis: $count credential(s)"

echo "== Bitstring Status List =="
STATUS_ISSUED_ON="$(git show -s --format=%aI HEAD)" node .github/scripts/sign-status-list.mjs

echo "== Flux annuaire =="
DIRECTORY_GENERATED_AT="$(git show -s --format=%aI HEAD)" node .github/scripts/build-directory.mjs

echo "== Assemblage de _site =="
mkdir -p _site/keys _site/u _site/status _site/photos
cp public/issuer.json _site/issuer.json
cp site/verify.mjs site/verify-page.mjs site/bitstring.mjs _site/
cp site/verify.html _site/index.html
cp directory.json _site/directory.json
if [ -d keys ]; then cp keys/*.json _site/keys/ 2>/dev/null || true; fi
if [ -d u ]; then cp -r u/* _site/u/ 2>/dev/null || true; fi
for p in data/members/*/photo.webp; do
[ -f "$p" ] || continue
h="$(basename "$(dirname "$p")")"; cp "$p" "_site/photos/$h.webp"
done
if [ -f status/1 ]; then cp status/1 _site/status/1; fi
echo "== _site prêt =="
101 changes: 101 additions & 0 deletions .github/workflows/deploy-vps.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# Déploiement sur le VPS (alternative à GitHub Pages). Calqué sur ai-driven-dev.fr :
# build+signe en cloud GitHub (la clé privée n'y touche jamais le VPS), image nginx
# statique poussée sur GHCR, puis le runner self-hosted fait `docker compose pull && up`.
#
# GATÉ (workflow_dispatch) tant que le VPS n'est pas câblé (voir docs/DEPLOY-VPS.md).
# Quand tout est prêt : ajouter le trigger `push` et retirer emit-and-deploy.yml (Pages).
name: Déployer sur le VPS

on:
workflow_dispatch:

permissions: read-all

concurrency:
group: deploy-vps
cancel-in-progress: false

env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}

jobs:
build-and-push:
name: Build + signe + push GHCR
runs-on: ubuntu-latest # GitHub-hosted : la clé privée reste dans le cloud GitHub
environment: signing # main uniquement, expose SIGNING_PRIVATE_KEY
permissions:
contents: read
packages: write
outputs:
image: ${{ steps.meta.outputs.tags }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
lfs: true

- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "20"

- run: cd .github/scripts && npm ci

- name: Construire le site (signe + assemble _site)
env:
SIGNING_PRIVATE_KEY: ${{ secrets.SIGNING_PRIVATE_KEY }}
run: bash .github/scripts/build-site.sh

- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,format=long
type=raw,value=latest,enable={{is_default_branch}}

- uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

deploy:
name: Déployer sur le VPS
needs: build-and-push
runs-on: [self-hosted, aidd-central]
permissions:
packages: read
environment:
name: production-vps
steps:
- name: Pull + restart le conteneur
env:
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GHCR_USER: ${{ github.actor }}
DEPLOY_DIR: ${{ vars.DEPLOY_PATH || '/opt/infrastructure/services/aidd-badges' }}
CONTAINER: aidd-badges-verify
run: |
set -euo pipefail
cd "$DEPLOY_DIR"
echo "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_USER" --password-stdin
docker compose pull
docker compose up -d --remove-orphans
for i in $(seq 1 20); do
S=$(docker inspect --format='{{.State.Health.Status}}' "$CONTAINER" 2>/dev/null || echo starting)
[ "$S" = "healthy" ] && { echo "healthy après ${i}x5s"; break; }
[ "$i" -eq 20 ] && { echo "PAS healthy"; docker logs --tail 50 "$CONTAINER"; exit 1; }
sleep 5
done
IP=$(docker inspect --format='{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$CONTAINER")
CODE=$(curl -sf -o /dev/null -w '%{http_code}' "http://${IP}:8080/issuer.json" || echo 000)
[ "$CODE" = "200" ] || { echo "smoke test HTTP $CODE"; exit 1; }
echo "Déployé, smoke test HTTP $CODE"
docker logout ghcr.io
49 changes: 5 additions & 44 deletions .github/workflows/emit-and-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ on:
- ".github/scripts/sign-credential.mjs"
- ".github/scripts/sign-status-list.mjs"
- ".github/scripts/build-directory.mjs"
- ".github/scripts/build-site.sh"
- ".github/scripts/lib/**"
- ".github/workflows/emit-and-deploy.yml"
workflow_dispatch:
Expand Down Expand Up @@ -47,53 +48,13 @@ jobs:
- name: Installer les dépendances des scripts
run: cd .github/scripts && npm ci

- name: Émettre les credentials (déterministe)
- name: Construire le site (signe + assemble _site)
env:
SIGNING_PRIVATE_KEY: ${{ secrets.SIGNING_PRIVATE_KEY }}
run: |
shopt -s nullglob
count=0
for d in data/members/*/; do
f="${d}record.yml"
[ -f "$f" ] || continue
certified_on="$(git log --diff-filter=A --format=%aI -- "$f" | tail -1)"
if [ -z "$certified_on" ]; then
echo "::warning::pas de commit d'ajout pour $f, ignoré"; continue
fi
CERTIFIED_ON="$certified_on" node .github/scripts/sign-credential.mjs "$f"
handle="$(basename "$d")"
cp site/badge-page.html "u/$handle/index.html" # page de vérif du membre
count=$((count+1))
done
echo "émis: $count credential(s)"
run: bash .github/scripts/build-site.sh

- name: Émettre la Bitstring Status List (révocation)
env:
SIGNING_PRIVATE_KEY: ${{ secrets.SIGNING_PRIVATE_KEY }}
run: |
STATUS_ISSUED_ON="$(git show -s --format=%aI HEAD)" \
node .github/scripts/sign-status-list.mjs

- name: Construire le flux annuaire (directory.json)
run: |
DIRECTORY_GENERATED_AT="$(git show -s --format=%aI HEAD)" \
node .github/scripts/build-directory.mjs

- name: Assembler le site statique
run: |
mkdir -p _site/keys _site/u _site/status _site/photos
cp public/issuer.json _site/issuer.json
cp site/verify.mjs site/verify-page.mjs site/bitstring.mjs _site/ # code de vérif (navigateur)
cp site/verify.html _site/index.html # page de vérif générique
cp directory.json _site/directory.json # flux annuaire (#33/#49)
if [ -d keys ]; then cp keys/*.json _site/keys/ 2>/dev/null || true; fi
if [ -d u ]; then cp -r u/* _site/u/ 2>/dev/null || true; fi
for p in data/members/*/photo.webp; do # publier la photo sous /photos/<handle>.webp
[ -f "$p" ] || continue
h="$(basename "$(dirname "$p")")"; cp "$p" "_site/photos/$h.webp"
done
if [ -f status/1 ]; then cp status/1 _site/status/1; fi
echo "verify.ai-driven-dev.fr" > _site/CNAME
- name: Fichier CNAME (spécifique à Pages)
run: echo "verify.ai-driven-dev.fr" > _site/CNAME

- uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3
with:
Expand Down
12 changes: 12 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Sert le site de vérification statique (_site) via nginx. L'image est construite
# APRÈS la signature (en CI GitHub-hosted) : _site contient déjà les preuves signées,
# les clés publiques, la status list, l'annuaire et le code de vérif. Aucun secret ici.
FROM nginx:1.27-alpine

COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY _site/ /usr/share/nginx/html/

HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:8080/issuer.json || exit 1

EXPOSE 8080
17 changes: 17 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Sert le site de vérification sur le VPS. L'image est construite et poussée sur
# GHCR par la CI (après signature) ; le VPS ne fait que pull + up. Le reverse proxy
# de l'infra route verify.ai-driven-dev.fr vers le port publié ci-dessous.
services:
verify:
image: ${IMAGE:-ghcr.io/ai-driven-dev/badges:latest}
container_name: aidd-badges-verify
ports:
- "${PORT:-8092}:8080"
restart: unless-stopped
init: true
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:8080/issuer.json"]
interval: 30s
timeout: 5s
retries: 3
start_period: 5s
57 changes: 57 additions & 0 deletions docs/DEPLOY-VPS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Déploiement sur le VPS (au lieu de GitHub Pages)

Aujourd'hui la vérif est servie par **GitHub Pages** (`emit-and-deploy.yml`). Cette
note décrit comment basculer sur le **VPS**, en calquant `ai-driven-dev.fr`.

## Principe (sécurité)

La **signature reste en cloud GitHub** : le job `build-and-push` tourne sur un runner
GitHub-hosted, dans l'environnement `signing`. La clé privée **ne touche jamais le VPS**.
Seule l'**image nginx** (sortie publique : `_site` signé) est poussée sur GHCR, puis le
runner self-hosted fait `docker compose pull && up`. GitHub n'est jamais sollicité par un
visiteur.

```
GitHub (build + signe) → image GHCR → VPS (docker compose) → verify.ai-driven-dev.fr
```

## Ce que le workflow fait déjà

`deploy-vps.yml` (gaté `workflow_dispatch`) :
1. build + signe `_site`, construit l'image (Dockerfile nginx), pousse sur `ghcr.io/ai-driven-dev/badges` ;
2. sur le runner `[self-hosted, aidd-central]` : `docker compose pull && up`, healthcheck, smoke test.

L'image sert sur le port **8080** ; `docker-compose.yml` le publie sur `${PORT:-8092}`.

## Étapes côté VPS (à faire une fois)

1. **Placer `docker-compose.yml`** du repo dans un dossier de déploiement, ex.
`/opt/infrastructure/services/aidd-badges/` (= la variable `DEPLOY_PATH`).
2. **Reverse proxy** : router `verify.ai-driven-dev.fr` → `http://127.0.0.1:<PORT>`
(le port publié par le compose), avec TLS. Même mécanisme que pour le site principal.
3. **DNS** : `verify.ai-driven-dev.fr` → l'IP du VPS.
4. **Variables du repo** (Settings → Actions → Variables) si les défauts ne conviennent
pas : `DEPLOY_PATH`, `PORT`.
5. **Environnement `production-vps`** : créer l'environnement (protections au choix).

## Basculer Pages → VPS

Une fois le VPS servi et le DNS en place :
1. Dans `deploy-vps.yml`, ajouter le trigger `push` (branche `main`, mêmes `paths` que
`emit-and-deploy.yml`) en plus de `workflow_dispatch`.
2. Désactiver / supprimer `emit-and-deploy.yml` (Pages) et retirer le fichier `CNAME`.
3. Repointer le DNS de `verify.*` de Pages vers le VPS.

Réversible : les badges référencent le **domaine**, pas l'hébergeur. Repasser sur Pages =
réactiver `emit-and-deploy.yml` et repointer le DNS.

## Tester l'image en local

```bash
# produire un _site de démo puis builder/lancer l'image
node .github/scripts/demo.mjs # écrit .demo-site (Ctrl+C après "servie")
cp -r .demo-site _site
docker build -t badges-verify .
docker run --rm -p 8092:8080 badges-verify
# -> http://localhost:8092/issuer.json, /u/demo, etc.
```
26 changes: 26 additions & 0 deletions nginx.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
index index.html;

# Types corrects pour le code de vérif et les preuves.
types {
text/html html;
text/javascript mjs;
application/json json;
image/svg+xml svg;
image/webp webp;
}
# credential.jwt et status/1 : pas d'extension -> texte brut.
location ~ /credential\.jwt$ { default_type text/plain; }
location = /status/1 { default_type text/plain; }

# CORS en lecture : la vérif indépendante et le site principal lisent ces fichiers.
add_header Access-Control-Allow-Origin "*" always;

# Fiche de membre : /u/<handle> -> /u/<handle>/index.html
location / {
try_files $uri $uri/ $uri/index.html =404;
}
}
Loading