feat: add OVH FTP preview deployment

This commit is contained in:
Clément SAILLANT
2026-03-14 14:29:27 +00:00
parent 49b0f6ae23
commit 3c641ba79a
17 changed files with 329 additions and 104 deletions
+4 -1
View File
@@ -3,4 +3,7 @@ FTP_USER=ecobsoleiq
FTP_PORT=21
FTP_REMOTE_DIR=/www
FTP_PASS=your-ftp-password
PUBLIC_SITE_URL=https://ton-domaine.fr/
PUBLIC_SITE_URL_PROD=https://www.lelectronrare.fr/
PUBLIC_SITE_URL_PREVIEW=https://www.lelectronrare.fr/preview/
FTP_DNS_RETRIES=5
FTP_DNS_DELAY=2
+61
View File
@@ -0,0 +1,61 @@
name: Deploy External Site to OVH FTP
on:
workflow_dispatch:
inputs:
target:
description: Deployment target
required: true
default: preview
type: choice
options:
- preview
- production
permissions:
contents: read
concurrency:
group: ovh-ftp-${{ inputs.target }}
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
env:
FTP_HOST: ${{ secrets.OVH_FTP_HOST }}
FTP_USER: ${{ secrets.OVH_FTP_USER }}
FTP_PASS: ${{ secrets.OVH_FTP_PASS }}
FTP_PORT: ${{ secrets.OVH_FTP_PORT }}
FTP_REMOTE_DIR: ${{ secrets.OVH_FTP_REMOTE_DIR }}
PUBLIC_SITE_URL_PROD: ${{ vars.PUBLIC_SITE_URL_PROD }}
PUBLIC_SITE_URL_PREVIEW: ${{ vars.PUBLIC_SITE_URL_PREVIEW }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: |
package-lock.json
apps/lab-interactif/package-lock.json
- name: Install root dependencies
run: npm ci
- name: Install lab dependencies
run: npm ci --prefix apps/lab-interactif
- name: Install lftp
run: |
sudo apt-get update
sudo apt-get install -y lftp
- name: Deploy target
env:
DEPLOY_TARGET_SKIP_BUILD: "0"
run: bash scripts/deploy-ovh-ftp-target.sh "${{ inputs.target }}"
+1 -1
View File
@@ -10,6 +10,6 @@
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
<script type="module" src="./src/main.jsx"></script>
</body>
</html>
+2 -1
View File
@@ -2,13 +2,14 @@ import { NavLink, Route, Routes } from 'react-router-dom';
import { LabHomePage } from './pages/LabHomePage';
import { SignalsPage } from './pages/SignalsPage';
import { PrototypesPage } from './pages/PrototypesPage';
import { withSiteBase } from './lib/paths';
export function App() {
return (
<div className="lab-app">
<header className="lab-header">
<div className="lab-shell">
<a className="lab-brand" href="/">
<a className="lab-brand" href={withSiteBase('/')}>
Retour site principal
</a>
<nav aria-label="Navigation lab">
+29
View File
@@ -0,0 +1,29 @@
const normalizeBaseUrl = (value) => (value.endsWith('/') ? value : `${value}/`);
export const LAB_BASE_URL = normalizeBaseUrl(import.meta.env.BASE_URL || '/lab/');
export const SITE_BASE_URL = LAB_BASE_URL.replace(/lab\/$/, '');
const isExternalUrl = (value) =>
/^(?:[a-z]+:)?\/\//i.test(value) || value.startsWith('mailto:') || value.startsWith('tel:');
export function withSiteBase(value) {
if (!value || isExternalUrl(value)) {
return value;
}
if (value.startsWith('/#')) {
return `${SITE_BASE_URL}#${value.slice(2)}`;
}
if (value.startsWith('#') || value.startsWith('?')) {
return value;
}
const normalizedPath = value.startsWith('/') ? value.slice(1) : value;
return `${SITE_BASE_URL}${normalizedPath}`;
}
export function labHashHref(pathname = '/') {
const normalizedPath = pathname === '/' ? '/' : pathname.startsWith('/') ? pathname : `/${pathname}`;
return `${LAB_BASE_URL}#${normalizedPath}`;
}
@@ -1,4 +1,8 @@
import { withSiteBase } from '../lib/paths';
export function LabHomePage() {
const labPublicPath = `${withSiteBase('/lab/')}`;
return (
<section className="lab-panel">
<p className="lab-kicker">Module C</p>
@@ -18,7 +22,7 @@ export function LabHomePage() {
<article>
<h2>Contrat d'integration</h2>
<p>
Chemin public: <code>/lab/</code>. Deploiement via build Vite, independent du template statique.
Chemin public: <code>{labPublicPath}</code>. Deploiement via build Vite, independent du template statique.
</p>
</article>
</div>
+7 -1
View File
@@ -1,9 +1,15 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
const normalizeUrl = (value) => (value.endsWith('/') ? value : `${value}/`);
const rawSiteUrl = process.env.PUBLIC_SITE_URL || process.env.EXTERNAL_SITE_URL || 'https://electron-rare.github.io/';
const parsedSiteUrl = new URL(normalizeUrl(rawSiteUrl));
const siteBasePath = parsedSiteUrl.pathname === '/' ? '' : parsedSiteUrl.pathname.replace(/\/$/, '');
const labBase = `${siteBasePath}/lab/`;
export default defineConfig({
plugins: [react()],
base: '/lab/',
base: labBase,
build: {
outDir: '../../lab',
emptyOutDir: true
+6 -1
View File
@@ -2,10 +2,15 @@ import { defineConfig } from 'astro/config';
import react from '@astrojs/react';
import tailwindcss from '@tailwindcss/vite';
const siteUrl = process.env.PUBLIC_SITE_URL || 'https://electron-rare.github.io/';
const normalizeUrl = (value) => (value.endsWith('/') ? value : `${value}/`);
const rawSiteUrl = process.env.PUBLIC_SITE_URL || process.env.EXTERNAL_SITE_URL || 'https://electron-rare.github.io/';
const siteUrl = normalizeUrl(rawSiteUrl);
const sitePathname = new URL(siteUrl).pathname;
const basePath = sitePathname === '/' ? '/' : sitePathname.replace(/\/$/, '');
export default defineConfig({
site: siteUrl,
base: basePath,
integrations: [react()],
vite: {
plugins: [tailwindcss()]
@@ -0,0 +1,50 @@
# OVH FTP Preview Deploy
## Pourquoi
Le deploy FTP direct depuis la machine locale bloque sur le canal de donnees FTP OVH.
Le build local fonctionne, mais l'upload reste instable.
## Solution retenue
Deployer vers OVH FTP depuis un runner GitHub Actions public.
Le runner :
- build le site avec la bonne `PUBLIC_SITE_URL`
- choisit la cible `preview` ou `production`
- synchronise `dist/` via `lftp`
## Secrets GitHub requis
- `OVH_FTP_HOST`
- `OVH_FTP_USER`
- `OVH_FTP_PASS`
- `OVH_FTP_PORT`
- `OVH_FTP_REMOTE_DIR`
## Variables GitHub requises
- `PUBLIC_SITE_URL_PROD=https://www.lelectronrare.fr/`
- `PUBLIC_SITE_URL_PREVIEW=https://www.lelectronrare.fr/preview/`
## Workflow
Fichier :
- `.github/workflows/deploy-ovh-ftp.yml`
Script cible :
- `scripts/deploy-ovh-ftp-target.sh`
## Usage
### Preview
```bash
gh workflow run deploy-ovh-ftp.yml --ref <branche> -f target=preview
```
### Production
```bash
gh workflow run deploy-ovh-ftp.yml --ref <branche> -f target=production
```
+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 700" role="img" aria-label="Electron rare logo lockup"><image href="/assets/brand/logo-lockup.png" width="1400" height="700" preserveAspectRatio="xMidYMid meet"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 700" role="img" aria-label="Electron rare logo lockup"><image href="logo-lockup.png" width="1400" height="700" preserveAspectRatio="xMidYMid meet"/></svg>

Before

Width:  |  Height:  |  Size: 227 B

After

Width:  |  Height:  |  Size: 213 B

+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" role="img" aria-label="Electron rare logo mark"><image href="/assets/brand/logo-mark.png" width="1024" height="1024" preserveAspectRatio="xMidYMid meet"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" role="img" aria-label="Electron rare logo mark"><image href="logo-mark.png" width="1024" height="1024" preserveAspectRatio="xMidYMid meet"/></svg>

Before

Width:  |  Height:  |  Size: 225 B

After

Width:  |  Height:  |  Size: 211 B

+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" role="img" aria-label="Electron rare favicon"><image href="/assets/brand/logo-mark.png" width="1024" height="1024" preserveAspectRatio="xMidYMid meet"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" role="img" aria-label="Electron rare favicon"><image href="assets/brand/logo-mark.png" width="1024" height="1024" preserveAspectRatio="xMidYMid meet"/></svg>

Before

Width:  |  Height:  |  Size: 223 B

After

Width:  |  Height:  |  Size: 222 B

+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
set -euo pipefail
TARGET="${1:-preview}"
LOCAL_DIR="${2:-dist}"
FTP_ENV_FILE="${FTP_ENV_FILE:-.env.ftp}"
load_env_file() {
if [[ ! -f "$FTP_ENV_FILE" ]]; then
return
fi
set -a
# shellcheck disable=SC1090
source "$FTP_ENV_FILE"
set +a
}
resolve_base_remote_dir() {
if [[ -n "${FTP_REMOTE_DIR:-}" ]]; then
printf '%s\n' "${FTP_REMOTE_DIR%/}"
return
fi
if [[ -n "${FTP_URL:-}" && "$FTP_URL" =~ ^ftp://[^@:/]+@[^:/]+(:([0-9]+))?(/.*)?$ ]]; then
printf '%s\n' "${BASH_REMATCH[3]:-/www}"
return
fi
printf '%s\n' "/www"
}
set_target_env() {
local base_remote_dir
base_remote_dir="$(resolve_base_remote_dir)"
case "$TARGET" in
preview)
export PUBLIC_SITE_URL="${PUBLIC_SITE_URL_PREVIEW:-https://www.lelectronrare.fr/preview/}"
export FTP_REMOTE_DIR="${base_remote_dir}/preview"
;;
production|prod)
export PUBLIC_SITE_URL="${PUBLIC_SITE_URL_PROD:-${PUBLIC_SITE_URL:-https://www.lelectronrare.fr/}}"
export FTP_REMOTE_DIR="${base_remote_dir}"
;;
*)
echo "Unknown target: ${TARGET}. Use 'preview' or 'production'."
exit 1
;;
esac
unset FTP_URL
}
main() {
load_env_file
set_target_env
if [[ "${DEPLOY_TARGET_SKIP_BUILD:-0}" != "1" ]]; then
npm run build:external
else
echo "[deploy-ovh-ftp-target] Skipping build:external (already built)."
fi
bash scripts/deploy-ovh-ftp.sh "$LOCAL_DIR"
}
main "$@"
+14 -12
View File
@@ -1,5 +1,6 @@
import * as React from 'react';
import type { CtaLink } from '@/lib/types';
import { withSiteBase } from '@/lib/site';
import { TRACK_EVENTS, trackAttrs } from '@/lib/tracking';
const heroCtas: CtaLink[] = [
@@ -24,6 +25,7 @@ const heroCtas: CtaLink[] = [
];
const HERO_TITLE_WORDS = ['Conception', 'electronique', 'sur mesure'];
const assetPath = (value: string) => withSiteBase(value);
const HERO_VARIANT_ASSETS: Record<
string,
@@ -45,18 +47,18 @@ const HERO_VARIANT_ASSETS: Record<
}
> = {
v12: {
routingDesktopWebp: '/assets/da/openai/hero-pcb-routing-map-v2.webp',
routingMobileWebp: '/assets/da/openai/hero-pcb-routing-map-mobile-low-noise-v2.webp',
routingDesktop: '/assets/da/openai/hero-pcb-routing-map-v2.png',
routingMobile: '/assets/da/openai/hero-pcb-routing-map-mobile-low-noise-v2.png',
instrumentDesktopWebp: '/assets/da/openai/hero-instrument-panel-v2.webp',
instrumentMobileWebp: '/assets/da/openai/hero-instrument-panel-mobile-low-noise-v2.webp',
instrumentDesktop: '/assets/da/openai/hero-instrument-panel-v2.png',
instrumentMobile: '/assets/da/openai/hero-instrument-panel-mobile-low-noise-v2.png',
notebookDesktopWebp: '/assets/da/openai/hero-carnet-labo-open-v2.webp',
notebookMobileWebp: '/assets/da/openai/carnet-labo-ouvert.webp',
notebookDesktop: '/assets/da/openai/hero-carnet-labo-open-v2.png',
notebookMobile: '/assets/da/openai/carnet-labo-ouvert.png',
routingDesktopWebp: assetPath('/assets/da/openai/hero-pcb-routing-map-v2.webp'),
routingMobileWebp: assetPath('/assets/da/openai/hero-pcb-routing-map-mobile-low-noise-v2.webp'),
routingDesktop: assetPath('/assets/da/openai/hero-pcb-routing-map-v2.png'),
routingMobile: assetPath('/assets/da/openai/hero-pcb-routing-map-mobile-low-noise-v2.png'),
instrumentDesktopWebp: assetPath('/assets/da/openai/hero-instrument-panel-v2.webp'),
instrumentMobileWebp: assetPath('/assets/da/openai/hero-instrument-panel-mobile-low-noise-v2.webp'),
instrumentDesktop: assetPath('/assets/da/openai/hero-instrument-panel-v2.png'),
instrumentMobile: assetPath('/assets/da/openai/hero-instrument-panel-mobile-low-noise-v2.png'),
notebookDesktopWebp: assetPath('/assets/da/openai/hero-carnet-labo-open-v2.webp'),
notebookMobileWebp: assetPath('/assets/da/openai/carnet-labo-ouvert.webp'),
notebookDesktop: assetPath('/assets/da/openai/hero-carnet-labo-open-v2.png'),
notebookMobile: assetPath('/assets/da/openai/carnet-labo-ouvert.png'),
routingCaption: 'Architecture pcb',
instrumentCaption: 'Appareil de mesure (asset)'
}
+29 -73
View File
@@ -1,5 +1,14 @@
---
import { GTM_CONTAINER_ID_DEFAULT, SITE_META, SITE_OG_IMAGE_URL, SITE_URL } from '@/lib/site';
import {
CANONICAL_URL,
GTM_CONTAINER_ID_DEFAULT,
PUBLIC_SITE_ROOT_URL,
SITE_IS_SUBPATH_BUILD,
SITE_META,
SITE_OG_IMAGE_URL,
stripSiteBase,
withSiteBase
} from '@/lib/site';
interface Props {
title?: string;
@@ -11,25 +20,28 @@ const {
description = SITE_META.description
} = Astro.props;
const canonical = SITE_URL;
const pagePath = stripSiteBase(Astro.url.pathname);
const canonical = new URL(pagePath, CANONICAL_URL).href;
const pageUrl = new URL(pagePath, PUBLIC_SITE_ROOT_URL).href;
const robotsContent = SITE_IS_SUBPATH_BUILD ? 'noindex,nofollow,noarchive' : 'index,follow,max-image-preview:large';
const gtmId = import.meta.env.PUBLIC_GTM_CONTAINER_ID || GTM_CONTAINER_ID_DEFAULT;
const structuredDataJsonLd = JSON.stringify({
'@context': 'https://schema.org',
'@graph': [
{
'@type': 'WebSite',
'@id': `${canonical}#website`,
url: canonical,
'@id': `${CANONICAL_URL}#website`,
url: CANONICAL_URL,
name: "L'électron rare",
inLanguage: 'fr-FR',
description: SITE_META.description
},
{
'@type': 'Organization',
'@id': `${canonical}#organization`,
'@id': `${CANONICAL_URL}#organization`,
name: "L'électron rare",
url: canonical,
logo: `${canonical}assets/brand/logo-mark.png`,
url: CANONICAL_URL,
logo: new URL('assets/brand/logo-mark.png', CANONICAL_URL).href,
sameAs: [
'https://fr.linkedin.com/in/electron-rare',
'https://www.malt.com/profile/clementsaillant',
@@ -38,15 +50,15 @@ const structuredDataJsonLd = JSON.stringify({
},
{
'@type': 'Person',
'@id': `${canonical}#person`,
'@id': `${CANONICAL_URL}#person`,
name: 'Clément Saillant',
alternateName: "L'électron rare",
url: canonical,
url: CANONICAL_URL,
jobTitle: 'Concepteur en création électronique, design produit et systèmes expérimentaux',
description:
"Codeur créatif, itérateur IA et inventeur de systèmes : création électronique, performances audiovisuelles et collaborations studio sous l'identité L'électron rare.",
worksFor: {
'@id': `${canonical}#organization`
'@id': `${CANONICAL_URL}#organization`
},
sameAs: [
'https://fr.linkedin.com/in/electron-rare',
@@ -66,20 +78,20 @@ const structuredDataJsonLd = JSON.stringify({
<title>{title}</title>
<meta name="theme-color" content={SITE_META.themeColor} />
<meta name="description" content={description} />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="icon" type="image/svg+xml" href={withSiteBase('/favicon.svg')} />
<link rel="icon" type="image/png" sizes="32x32" href={withSiteBase('/favicon-32x32.png')} />
<link rel="icon" type="image/png" sizes="16x16" href={withSiteBase('/favicon-16x16.png')} />
<link rel="apple-touch-icon" sizes="180x180" href={withSiteBase('/apple-touch-icon.png')} />
<link rel="shortcut icon" href={withSiteBase('/favicon.ico')} />
<link rel="canonical" href={canonical} />
<meta name="robots" content="index,follow,max-image-preview:large" />
<meta name="robots" content={robotsContent} />
<meta property="og:type" content="website" />
<meta property="og:locale" content="fr_FR" />
<meta property="og:site_name" content="L'électron rare" />
<meta property="og:title" content={title} />
<meta property="og:url" content={canonical} />
<meta property="og:url" content={pageUrl} />
<meta property="og:description" content={SITE_META.ogDescription} />
<meta property="og:image" content={SITE_OG_IMAGE_URL} />
<meta property="og:image:width" content="1200" />
@@ -413,62 +425,6 @@ const structuredDataJsonLd = JSON.stringify({
})();
</script>
<script is:inline>
(function () {
var root = document.documentElement;
var key = "lelectron-theme";
var defaultTheme = "default";
var lightTheme = 'light';
var highContrastTheme = 'high-contrast';
var themes = [defaultTheme, lightTheme, highContrastTheme];
// Prefer the atelier (light) theme by default; allow cycling to standard + high contrast.
var preferred = window.matchMedia && window.matchMedia('(prefers-contrast: more)').matches ? highContrastTheme : lightTheme;
var labelForTheme = function (theme) {
if (theme === lightTheme) {
return 'Mode contraste';
}
if (theme === highContrastTheme) {
return 'Mode standard';
}
return 'Mode clair';
};
var applyTheme = function (theme) {
root.setAttribute('data-theme', theme);
window.localStorage.setItem(key, theme);
var button = document.querySelector('[data-theme-toggle]');
if (!button) {
return;
}
button.textContent = labelForTheme(theme);
button.setAttribute('aria-pressed', theme === highContrastTheme ? 'true' : 'false');
};
var toggleTheme = function () {
var current = root.getAttribute('data-theme') || defaultTheme;
var index = themes.indexOf(current);
var nextTheme = themes[(index + 1) % themes.length];
applyTheme(nextTheme);
};
var stored = window.localStorage.getItem(key);
if (stored === defaultTheme || stored === lightTheme || stored === highContrastTheme) {
root.setAttribute('data-theme', stored);
} else {
root.setAttribute('data-theme', preferred);
}
var button = document.querySelector('[data-theme-toggle]');
if (button) {
button.addEventListener('click', toggleTheme);
}
applyTheme(root.getAttribute('data-theme') || defaultTheme);
})();
</script>
<script is:inline>
(function () {
var nav = document.querySelector('.site-nav');
+49 -7
View File
@@ -1,6 +1,53 @@
const normalizeUrl = (value: string) => (value.endsWith('/') ? value : `${value}/`);
export const SITE_URL = normalizeUrl(import.meta.env.PUBLIC_SITE_URL || 'https://electron-rare.github.io/');
const resolvedSiteUrl = import.meta.env.PUBLIC_SITE_URL || 'https://electron-rare.github.io/';
const parsedSiteUrl = new URL(normalizeUrl(resolvedSiteUrl));
const resolvedBasePath = parsedSiteUrl.pathname === '/' ? '' : parsedSiteUrl.pathname.replace(/\/$/, '');
export const SITE_URL = parsedSiteUrl.href;
export const SITE_BASE_PATH = resolvedBasePath;
export const SITE_IS_SUBPATH_BUILD = SITE_BASE_PATH !== '';
export const CANONICAL_URL = 'https://www.lelectronrare.fr/';
export const PUBLIC_SITE_ROOT_URL = SITE_IS_SUBPATH_BUILD ? SITE_URL : CANONICAL_URL;
export function withSiteBase(value: string) {
if (!value) {
return SITE_BASE_PATH || '/';
}
if (/^(?:[a-z]+:)?\/\//i.test(value) || value.startsWith('mailto:') || value.startsWith('tel:')) {
return value;
}
if (value.startsWith('#') || value.startsWith('?')) {
return value;
}
const normalizedPath = value.startsWith('/') ? value : `/${value}`;
return SITE_BASE_PATH ? `${SITE_BASE_PATH}${normalizedPath}` : normalizedPath;
}
export function stripSiteBase(pathname: string) {
if (!pathname) {
return '/';
}
if (!SITE_BASE_PATH) {
return pathname;
}
if (pathname === SITE_BASE_PATH) {
return '/';
}
if (pathname.startsWith(`${SITE_BASE_PATH}/`)) {
return pathname.slice(SITE_BASE_PATH.length) || '/';
}
return pathname;
}
export const SITE_META = {
title: "Clément Saillant — L'électron rare",
@@ -14,11 +61,6 @@ export const SITE_META = {
themeColor: '#f4eee3'
} as const;
export const SITE_OG_IMAGE_URL = `${SITE_URL}${SITE_META.ogImagePath}`;
export const SITE_OG_IMAGE_URL = new URL(SITE_META.ogImagePath, PUBLIC_SITE_ROOT_URL).href;
export const GTM_CONTAINER_ID_DEFAULT = 'GTM-5SLM67QF';
export const BRAND_ASSETS = {
markPath: 'assets/brand/logo-mark.png',
lockupPath: 'assets/brand/logo-lockup.png'
} as const;
-3
View File
@@ -26,9 +26,6 @@ import { TRACK_EVENTS, trackAttrs } from '@/lib/tracking';
<li><a href="#contact" class="figma-lab-nav-link site-navlink">#contact</a></li>
</ul>
</nav>
<button class="site-contrast-toggle" data-theme-toggle type="button" aria-pressed="false">
Mode contraste
</button>
</div>
</header>