[READ-ONLY] Mirror of https://github.com/thoda-dev/shhh. Self-hostable zero-knowledge pastebin for secrets that expire on their own
docker end-to-end-encryption nuxt nuxtjs pastebin secrets selft-hosted zero-knowledge
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339import { spawnSync } from 'node:child_process'import { readFileSync, writeFileSync } from 'node:fs'import { join } from 'node:path'import { tmpdir } from 'node:os'import { createInterface } from 'node:readline/promises'import { consola } from 'consola'import { generateNotes, prependToChangelog } from './changelog.mjs'
/** * Cutting a release by hand, so no secret ever passes through a GitHub Action. Replays the CI checks, then pushes the image and the tag. * * Usage: pnpm release <patch|minor|major|x.y.z> [options] * --dry-run runs nothing irreversible, prints the commands instead * --yes skips the confirmation * --skip-checks resumes after a failure, once the checks have already passed * --skip-docker does not publish the image (resume after a successful Docker push) * --skip-github pushes neither the tag nor the release * --no-open does not open the drafted release in a browser * --allow-branch allows a branch other than master */
const IMAGE = 'thodadev/shhh'const DOCS_IMAGE = 'thodadev/shhh-docs'// Mirrored on GHCR: Docker Hub caps anonymous pulls per IP, and a CGNAT shares that IP between subscribers.const GHCR_IMAGE = 'ghcr.io/thoda-dev/shhh'const GHCR_DOCS_IMAGE = 'ghcr.io/thoda-dev/shhh-docs'const PLATFORMS = 'linux/amd64,linux/arm64'const BUILDER = 'shhh-release'const BRANCH = 'master'const REPO_URL = 'https://github.com/thoda-dev/shhh'
const args = process.argv.slice(2)const flags = new Set(args.filter(a => a.startsWith('--')))const target = args.find(a => !a.startsWith('--'))const dryRun = flags.has('--dry-run')
function run(command, cmdArgs, options = {}) { const printable = `${command} ${cmdArgs.join(' ')}` if (dryRun && !options.readOnly) { consola.log(` [dry-run] ${printable}`) return { status: 0, stdout: '' } } const result = spawnSync(command, cmdArgs, { stdio: options.capture ? 'pipe' : 'inherit', encoding: 'utf8', env: { ...process.env, ...options.env } }) if (result.status !== 0 && !options.allowFailure) { consola.error(`Échec : ${printable}`) if (options.capture && result.stderr) consola.log(result.stderr.trim()) if (options.onFailure) options.onFailure() process.exit(1) } return { status: result.status, stdout: (result.stdout ?? '').trim() }}
// Read-only, so never stubbed out by --dry-run — otherwise the preflight would check nothing.function capture(command, cmdArgs) { return run(command, cmdArgs, { capture: true, allowFailure: true, readOnly: true }).stdout}
// No `shell: true`: Node deprecates passing args to a shell (DEP0190), and a missing binary reports itself through `error`, not an exit code.function has(binary) { return spawnSync(binary, ['--version'], { stdio: 'ignore' }).error === undefined}
// ---------------------------------------------------------------- version
const pkgPath = new URL('../package.json', import.meta.url)const pkgRaw = readFileSync(pkgPath, 'utf8')const current = JSON.parse(pkgRaw).version ?? '0.0.0'
function nextVersion(from, keyword) { const match = /^(\d+)\.(\d+)\.(\d+)/.exec(from) if (!match) { consola.error(`Version courante illisible dans package.json : ${from}`) process.exit(1) } const [major, minor, patch] = match.slice(1).map(Number) if (keyword === 'major') return `${major + 1}.0.0` if (keyword === 'minor') return `${major}.${minor + 1}.0` return `${major}.${minor}.${patch + 1}`}
if (!target) { consola.error('Usage : pnpm release <patch|minor|major|x.y.z> [--dry-run] [--yes]') consola.info(`Version courante : ${current}`) process.exit(1)}
const version = ['patch', 'minor', 'major'].includes(target) ? nextVersion(current, target) : target
// Full semver, prerelease and build metadata included — this is what allows a 1.0.0-rc.1.if (!/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$/.test(version)) { consola.error(`Version invalide : ${version}`) process.exit(1)}
const tag = `v${version}`// `latest` must never point at a prerelease: a self-hoster who doesn't pin would pull a release candidate without asking.const isPrerelease = version.includes('-')
// ---------------------------------------------------------------- preflight
consola.start(`Préparation de ${tag} (depuis ${current})`)
const problems = []
if (capture('git', ['rev-parse', '--is-inside-work-tree']) !== 'true') { problems.push('pas dans un dépôt git')}if (capture('git', ['status', '--porcelain'])) { problems.push('arbre de travail non propre — commite ou remise tes modifications')}
const branch = capture('git', ['rev-parse', '--abbrev-ref', 'HEAD'])if (branch !== BRANCH && !flags.has('--allow-branch')) { problems.push(`branche ${branch} au lieu de ${BRANCH} (--allow-branch pour passer outre)`)}
consola.info('Synchronisation avec origin…')run('git', ['fetch', '--quiet', '--tags', 'origin'], { readOnly: true, allowFailure: true })
const local = capture('git', ['rev-parse', 'HEAD'])const remote = capture('git', ['rev-parse', `origin/${branch}`])if (remote && local !== remote) { const ahead = capture('git', ['rev-list', '--count', `origin/${branch}..HEAD`]) const behind = capture('git', ['rev-list', '--count', `HEAD..origin/${branch}`]) problems.push(`branche désynchronisée avec origin (${ahead} devant, ${behind} derrière)`)}
if (capture('git', ['tag', '--list', tag])) { problems.push(`le tag ${tag} existe déjà en local`)}if (capture('git', ['ls-remote', '--tags', 'origin', tag])) { problems.push(`le tag ${tag} existe déjà sur origin`)}
if (!has('docker')) { problems.push('docker introuvable')} else if (capture('docker', ['buildx', 'version']) === '') { problems.push('docker buildx introuvable')}
// A `credsStore` entry proves nothing — Docker Desktop always writes it. Only the keychain can say which registries are actually in there.function loggedInRegistries() { let config try { config = JSON.parse(readFileSync(`${process.env.HOME}/.docker/config.json`, 'utf8')) } catch { return [] } return config.credsStore ? Object.keys(JSON.parse(capture(`docker-credential-${config.credsStore}`, ['list']) || '{}')) : Object.keys(config.auths ?? {})}
// Both checked before the build: `--skip-docker` is all or nothing, so a push landing on one registry only cannot be replayed by halves.const registries = loggedInRegistries()if (!registries.some(r => r.includes('docker.io'))) { problems.push('pas de session Docker Hub — lance `docker login`')}if (!registries.some(r => r.includes('ghcr.io'))) { problems.push('pas de session GHCR — lance `gh auth token | docker login ghcr.io -u <utilisateur> --password-stdin` (le jeton a besoin du scope write:packages)')}
const hasGh = has('gh') && spawnSync('gh', ['auth', 'status'], { stdio: 'ignore' }).status === 0if (!hasGh) { consola.warn('gh absent ou non authentifié : la release GitHub devra être créée à la main.')}
if (problems.length) { consola.error('Préflight échoué :') for (const p of problems) consola.log(` · ${p}`) process.exit(1)}
// ---------------------------------------------------------------- confirmation
// The tag ladder every official image publishes: `:1` keeps receiving 1.x fixes without ever crossing into a breaking 2.0, and `:1.2` narrows that to patches.// A prerelease gets only its exact version, so no moving tag ever resolves to it.const [major, minor] = version.split('.')const tagsFor = (...repos) => repos.flatMap(repo => isPrerelease ? [`${repo}:${version}`] : [`${repo}:${version}`, `${repo}:${major}.${minor}`, `${repo}:${major}`, `${repo}:latest`])
// The docs ride the app's version rather than carrying their own: they document that exact release,// so a reader can pin both to the same tag and know they match.const images = [ { name: 'app', dockerfile: 'docker/Dockerfile', tags: tagsFor(IMAGE, GHCR_IMAGE) }, { name: 'docs', dockerfile: 'docker/docs.Dockerfile', tags: tagsFor(DOCS_IMAGE, GHCR_DOCS_IMAGE) }]const imageTags = images.flatMap(i => i.tags)
consola.box([ `Version ${current} → ${version}`, `Tag git ${tag}`, `Image ${imageTags.join('\n ')}`, `Plateformes ${PLATFORMS}`, `Release ${hasGh ? 'gh release create' : 'à créer à la main'}`].join('\n'))
if (!flags.has('--yes') && !dryRun) { const rl = createInterface({ input: process.stdin, output: process.stdout }) const answer = await rl.question(`Confirmer la publication de ${tag} ? (tape le numéro de version) `) rl.close() if (answer.trim() !== version) { consola.error('Annulé.') process.exit(1) }}
// ---------------------------------------------------------------- checks
if (!flags.has('--skip-checks')) { consola.start('Vérifications (identiques au CI)') run('pnpm', ['install', '--frozen-lockfile']) run('pnpm', ['lint']) run('pnpm', ['typecheck']) run('pnpm', ['test']) // Only proves the code compiles: the image rebuilds from scratch inside the container, so any NUXT_PUBLIC_* injected here never reaches production. run('pnpm', ['build']) run('pnpm', ['build:docs'])
// The only command that has to run from apps/app, hence the direct spawnSync. if (!dryRun) { const migrations = spawnSync('./node_modules/.bin/drizzle-kit', ['check'], { cwd: 'apps/app', stdio: 'inherit', env: { ...process.env, DATABASE_URL: 'postgres://unused' } }) if (migrations.status !== 0) { consola.error('Les migrations ne correspondent pas au schéma.') process.exit(1) } } consola.success('Vérifications passées')}
// ---------------------------------------------------------------- commit and tag, locally
const undo = () => { consola.log('') consola.warn('Pour revenir en arrière :') consola.log(` git tag -d ${tag}`) consola.log(' git reset --hard HEAD~1')}
consola.start('Génération du changelog')const lastTag = capture('git', ['describe', '--tags', '--abbrev=0']) || undefinedconst notes = await generateNotes({ fromTag: lastTag, version })if (!notes) { consola.warn('Aucun commit à retenir depuis le dernier tag — changelog inchangé.')} else if (dryRun) { consola.log(' [dry-run] CHANGELOG.md aurait reçu :') consola.log(notes.split('\n').map(l => ` ${l}`).join('\n'))} else { prependToChangelog(notes) consola.success('CHANGELOG.md mis à jour')}
consola.start(`Commit et tag ${tag}`)if (!dryRun) writeFileSync(pkgPath, pkgRaw.replace(`"version": "${current}"`, `"version": "${version}"`))run('git', ['add', 'package.json', 'CHANGELOG.md'])run('git', ['commit', '-m', `chore(release): ${tag}`])run('git', ['tag', '-a', tag, '-m', tag], { onFailure: undo })
const sha = capture('git', ['rev-parse', 'HEAD'])
// ---------------------------------------------------------------- Docker image
if (!flags.has('--skip-docker')) { // The default `docker` driver can't produce a multi-architecture manifest, so a dedicated `docker-container` builder is created rather than touching the current one. const builders = capture('docker', ['buildx', 'ls']) if (!builders.includes(BUILDER)) { consola.info(`Création du builder ${BUILDER} (multi-architecture)`) run('docker', ['buildx', 'create', '--name', BUILDER, '--driver', 'docker-container'], { onFailure: undo }) }
for (const image of images) { consola.start(`Build et push de l'image ${image.name} (${PLATFORMS}) — c'est l'étape longue`) run('docker', [ 'buildx', 'build', '--builder', BUILDER, '--platform', PLATFORMS, '-f', image.dockerfile, ...image.tags.flatMap(t => ['-t', t]), '--label', `org.opencontainers.image.version=${version}`, '--label', `org.opencontainers.image.revision=${sha}`, '--label', `org.opencontainers.image.source=${REPO_URL}`, '--label', 'org.opencontainers.image.licenses=MIT', '--push', '.' ], { onFailure: undo }) consola.success(`Image publiée : ${image.tags.join(', ')}`) }}
// ---------------------------------------------------------------- GitHub
// After Docker on purpose: the tag is the public announcement, and you don't announce a version whose image doesn't exist. A Docker failure leaves only local, undoable state.if (!flags.has('--skip-github')) { consola.start('Push du commit et du tag') run('git', ['push', 'origin', branch], { onFailure: undo }) run('git', ['push', 'origin', tag])
if (hasGh) { // A draft, not a published release: the notes come from commit messages, which is a starting // point rather than an announcement. Publishing stays a human click, after reading them. consola.start('Brouillon de release GitHub') const notesFile = join(tmpdir(), `shhh-${tag}-notes.md`) if (!dryRun) writeFileSync(notesFile, notes || `Release ${tag}.`) run('gh', ['release', 'create', tag, '--title', tag, '--draft', '--notes-file', notesFile, ...(isPrerelease ? ['--prerelease'] : [])], { allowFailure: true })
if (!flags.has('--no-open')) { // `gh release view --web` resolves the draft's own URL, which is unguessable: an unpublished // release does not live at /releases/tag/<tag>. run('gh', ['release', 'view', tag, '--web'], { allowFailure: true }) } consola.info('La release est un brouillon — relis les notes, puis publie-la.') } else { consola.log('') consola.info('Release GitHub à créer à la main :') consola.log(` gh release create ${tag} --title ${tag} --draft --notes-file <fichier>`) consola.log(` ou ${REPO_URL}/releases/new?tag=${tag}`) if (notes) { consola.log('') consola.log('Notes générées :') consola.log(notes.split('\n').map(l => ` ${l}`).join('\n')) } }}
consola.success(`${tag} publiée.`)