237 lines
8.4 KiB
JavaScript
237 lines
8.4 KiB
JavaScript
#!/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()
|