#!/usr/bin/env node // ===================================================================== // Publication d'une nouvelle version. // // npm run release -- 0.3.0 "Ce qui change pour l'équipe" // // Ce que fait le script, dans l'ordre : // 1. écrit le numéro de version dans package.json et tauri.conf.json // 2. construit et SIGNE l'installeur Windows // 3. téléverse l'installeur dans le bucket public « releases » // 4. écrit latest.json — c'est ce fichier que les applications lisent // // À partir de là, chaque membre voit un bandeau « Version X disponible » // au lancement, clique, et c'est fait. // ===================================================================== import { execSync } from 'node:child_process' import { readFileSync, writeFileSync, existsSync, readdirSync } from 'node:fs' import { join, basename } from 'node:path' import { ROOT, KEY_PATH, signingEnv, tauriBin } from './signing.mjs' const BUCKET = 'releases' // --------------------------------------------------------------------- // Petits utilitaires // --------------------------------------------------------------------- const c = { ok: (m) => console.log(`\x1b[32m✓\x1b[0m ${m}`), step: (m) => console.log(`\n\x1b[1m\x1b[36m▸ ${m}\x1b[0m`), info: (m) => console.log(` ${m}`), warn: (m) => console.log(`\x1b[33m!\x1b[0m ${m}`) } function fail(message, hint) { console.error(`\n\x1b[31m✗ ${message}\x1b[0m`) if (hint) console.error(`\n${hint}\n`) process.exit(1) } /** Lit .env puis .env.local (le second gagne). */ function loadEnv() { const env = {} for (const file of ['.env', '.env.local']) { const path = join(ROOT, file) if (!existsSync(path)) continue for (const line of readFileSync(path, 'utf8').split(/\r?\n/)) { const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)$/i) if (m) env[m[1]] = m[2].trim().replace(/^["']|["']$/g, '') } } return env } function readJson(path) { return JSON.parse(readFileSync(path, 'utf8')) } function writeJson(path, data) { writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`, 'utf8') } // --------------------------------------------------------------------- // 1) Arguments et vérifications // --------------------------------------------------------------------- const [version, ...noteParts] = process.argv.slice(2) const notes = noteParts.join(' ').trim() if (!version || !/^\d+\.\d+\.\d+$/.test(version)) { fail( 'Numéro de version manquant ou invalide.', 'Utilisation :\n' + ' npm run release -- 1.2.3 "Ce qui change pour l\'équipe"\n\n' + 'Le numéro doit être au format X.Y.Z.' ) } const env = loadEnv() const SUPABASE_URL = env.VITE_SUPABASE_URL const SERVICE_KEY = env.SUPABASE_SERVICE_ROLE_KEY if (!SUPABASE_URL) fail('VITE_SUPABASE_URL absent du fichier .env') if (!SERVICE_KEY) { fail( 'SUPABASE_SERVICE_ROLE_KEY absent du fichier .env.local', 'Cette clé sert UNIQUEMENT à téléverser depuis ta machine — elle ne part\n' + "jamais dans l'application distribuée.\n\n" + 'Récupère-la dans Supabase → Settings → API → « service_role », puis\n' + 'crée un fichier .env.local à la racine avec :\n\n' + ' SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOi...\n\n' + '(.env.local est ignoré par git : la clé ne sera jamais publiée.)' ) } const currentPkg = readJson(join(ROOT, 'package.json')) if (currentPkg.version === version) { c.warn(`package.json est déjà en ${version} — on republie par-dessus.`) } // --------------------------------------------------------------------- // 2) Numéro de version // --------------------------------------------------------------------- c.step(`Version ${version}`) const pkgPath = join(ROOT, 'package.json') const pkg = readJson(pkgPath) pkg.version = version writeJson(pkgPath, pkg) c.ok('package.json') const confPath = join(ROOT, 'src-tauri', 'tauri.conf.json') const conf = readJson(confPath) conf.version = version writeJson(confPath, conf) c.ok('tauri.conf.json') // --------------------------------------------------------------------- // 3) Construction signée // --------------------------------------------------------------------- c.step('Construction de l\'installeur (plusieurs minutes)') try { execSync(`"${tauriBin()}" build`, { cwd: ROOT, stdio: 'inherit', env: signingEnv() }) } catch { fail('La construction a échoué. Corrige l\'erreur ci-dessus puis relance.') } // --------------------------------------------------------------------- // 4) Retrouver l'installeur et sa signature // --------------------------------------------------------------------- c.step('Recherche des fichiers produits') const bundleDir = join(ROOT, 'src-tauri', 'target', 'release', 'bundle') /** Cherche le premier fichier correspondant, dans l'ordre de préférence. */ function findBundle() { // NSIS d'abord : c'est le seul format que l'updater Windows sait installer // sans intervention (le .msi exige des droits administrateur à chaque fois). for (const [dir, ext] of [ ['nsis', '-setup.exe'], ['msi', '.msi'] ]) { const path = join(bundleDir, dir) if (!existsSync(path)) continue const file = readdirSync(path).find((f) => f.endsWith(ext) && f.includes(version)) if (file) return join(path, file) } return null } const installer = findBundle() if (!installer) { fail( `Aucun installeur trouvé pour la version ${version}.`, `Regarde dans ${bundleDir}.` ) } const sigPath = `${installer}.sig` if (!existsSync(sigPath)) { fail( 'Signature absente : l\'installeur n\'a pas été signé.', `Vérifie que ${KEY_PATH} existe et que la section "plugins.updater"\n` + 'de tauri.conf.json contient bien "pubkey".' ) } const signature = readFileSync(sigPath, 'utf8').trim() const installerName = basename(installer) const installerBytes = readFileSync(installer) c.ok(`${installerName} (${(installerBytes.length / 1048576).toFixed(1)} Mo)`) // --------------------------------------------------------------------- // 5) Téléversement // --------------------------------------------------------------------- c.step('Téléversement vers Supabase') async function upload(name, body, contentType) { const res = await fetch( `${SUPABASE_URL}/storage/v1/object/${BUCKET}/${encodeURIComponent(name)}`, { method: 'POST', headers: { Authorization: `Bearer ${SERVICE_KEY}`, 'Content-Type': contentType, 'x-upsert': 'true', // republier une version écrase l'ancienne 'cache-control': 'max-age=60' }, body } ) if (!res.ok) { const text = await res.text() if (/bucket not found/i.test(text)) { fail( `Le bucket « ${BUCKET} » n'existe pas.`, 'Exécute une seule fois, dans le SQL Editor de Supabase :\n\n' + " insert into storage.buckets (id, name, public)\n" + ` values ('${BUCKET}', '${BUCKET}', true) on conflict (id) do nothing;\n\n` + " create policy releases_read on storage.objects\n" + ` for select using (bucket_id = '${BUCKET}');` ) } fail(`Téléversement de ${name} refusé (HTTP ${res.status})`, text) } } await upload(installerName, installerBytes, 'application/octet-stream') c.ok(installerName) // --------------------------------------------------------------------- // 6) Le manifeste — c'est lui que les applications interrogent // --------------------------------------------------------------------- const manifest = { version, notes: notes || `Version ${version}`, pub_date: new Date().toISOString(), platforms: { 'windows-x86_64': { signature, url: `${SUPABASE_URL}/storage/v1/object/public/${BUCKET}/${encodeURIComponent(installerName)}` } } } await upload('latest.json', JSON.stringify(manifest, null, 2), 'application/json') c.ok('latest.json') // --------------------------------------------------------------------- c.step(`Version ${version} publiée 🎉`) c.info('Chaque membre verra le bandeau « Mise à jour disponible » au prochain') c.info('lancement (ou dans les 2 heures si son application reste ouverte).') c.info('') c.info('Pense à valider les fichiers modifiés :') c.info(' git add package.json src-tauri/tauri.conf.json && git commit -m "v' + version + '"') console.log()