This commit is contained in:
2026-08-10 22:06:49 +02:00
parent 2a5d9bb0fd
commit 8bff7982ff
83 changed files with 17975 additions and 4824 deletions
+236
View File
@@ -0,0 +1,236 @@
#!/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()