diff --git a/Makefile b/Makefile index 889f4cab..3ed1e57b 100644 --- a/Makefile +++ b/Makefile @@ -61,6 +61,17 @@ gows: (export PATH=${HOME}/go/bin:${PATH} || echo failed) && \ make all +ORIGIN ?= waha-plus +CORE_REMOTE ?= waha + +release: + node scripts/release.js + +release-push: release + git push $(ORIGIN) core plus + git push --force-with-lease $(ORIGIN) dev + git push $(CORE_REMOTE) core + up-dashboard: node scripts/up-dashboard.js diff --git a/scripts/release.js b/scripts/release.js new file mode 100644 index 00000000..83070dff --- /dev/null +++ b/scripts/release.js @@ -0,0 +1,218 @@ +#!/usr/bin/env node +'use strict'; + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { execFileSync } = require('child_process'); +// eslint-disable-next-line @typescript-eslint/no-var-requires +const readline = require('readline'); + +const DEV_BRANCH = process.env.WAHA_DEV_BRANCH || 'dev'; +const CORE_BRANCH = process.env.WAHA_CORE_BRANCH || 'core'; +const PLUS_BRANCH = process.env.WAHA_PLUS_BRANCH || 'plus'; + +const CORE_PREFIX = '[core]'; +const PLUS_PREFIX = '[PLUS]'; + +const args = process.argv.slice(2); +const dryRun = args.includes('--dry-run'); +const assumeYes = args.includes('--yes') || args.includes('-y'); + +function git(gitArgs, options) { + const opts = options || {}; + return execFileSync('git', gitArgs, { + encoding: 'utf8', + stdio: opts.inherit ? 'inherit' : ['ignore', 'pipe', 'pipe'], + }); +} + +function gitOut(gitArgs) { + return git(gitArgs, { inherit: false }).trim(); +} + +function log(message) { + console.log(message); +} + +function fail(message) { + console.error(`\n✗ ${message}`); + process.exit(1); +} + +function ensureCleanTree() { + const status = gitOut(['status', '--porcelain']); + if (status) { + fail( + 'Working tree is not clean. Commit or stash your changes before releasing.', + ); + } +} + +function ensureBranchExists(branch) { + try { + git(['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`]); + } catch { + fail(`Branch "${branch}" does not exist locally.`); + } +} + +// Non-merge commits in `boundary..head`, oldest first, whose subject starts +// with prefix. The boundary is the last-released plus tip: dev is rebased onto +// plus every release, so `plus..dev` is exactly the new, unreleased work. +// We intentionally do not use `git cherry` (patch-id matching) because core is +// a rewritten history whose old commits share no patch-ids with dev. +function commitsToCherryPick(boundary, head, prefix) { + const format = '%H%x09%s'; + const output = gitOut([ + 'rev-list', + '--reverse', + '--no-merges', + `--format=${format}`, + `${boundary}..${head}`, + ]); + if (!output) { + return []; + } + const picks = []; + for (const line of output.split('\n')) { + // rev-list --format prefixes each entry with a "commit " header line. + if (!line || line.startsWith('commit ')) { + continue; + } + const tab = line.indexOf('\t'); + const sha = line.slice(0, tab); + const subject = line.slice(tab + 1); + if (subject.startsWith(prefix)) { + picks.push({ sha: sha, subject: subject }); + } + } + return picks; +} + +function cherryPickAll(picks) { + for (const pick of picks) { + log(` cherry-pick ${pick.sha.slice(0, 9)} ${pick.subject}`); + if (dryRun) { + continue; + } + try { + git(['cherry-pick', pick.sha], { inherit: true }); + } catch { + git(['cherry-pick', '--abort'], { inherit: true }); + fail( + `Cherry-pick of ${pick.sha.slice(0, 9)} failed (conflict). ` + + `Aborted the cherry-pick — resolve manually and re-run.`, + ); + } + } +} + +function checkout(branch) { + log(`\n→ checkout ${branch}`); + if (!dryRun) { + git(['checkout', branch], { inherit: true }); + } +} + +function confirm(question) { + if (assumeYes || dryRun) { + return Promise.resolve(true); + } + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + return new Promise(function resolver(resolve) { + rl.question(`${question} [y/N] `, function onAnswer(answer) { + rl.close(); + resolve(/^y(es)?$/i.test(answer.trim())); + }); + }); +} + +async function main() { + ensureCleanTree(); + [DEV_BRANCH, CORE_BRANCH, PLUS_BRANCH].forEach(ensureBranchExists); + + const startBranch = gitOut(['rev-parse', '--abbrev-ref', 'HEAD']); + + if (dryRun) { + log('Running in --dry-run mode: no branches will be modified.\n'); + } + + // Capture the last-released plus tip before we touch anything. Step 3 merges + // core into plus and moves the branch, so both pick sets must be computed + // against this saved boundary, not the live plus ref. + const boundary = gitOut(['rev-parse', PLUS_BRANCH]); + log(`Boundary (last released ${PLUS_BRANCH}): ${boundary.slice(0, 9)}\n`); + + const corePicks = commitsToCherryPick(boundary, DEV_BRANCH, CORE_PREFIX); + log( + `Found ${corePicks.length} "${CORE_PREFIX}" commit(s) in ` + + `${DEV_BRANCH} since last ${PLUS_BRANCH} release.`, + ); + + // Step 1 + 2: bring missing [core] commits onto core. + checkout(CORE_BRANCH); + cherryPickAll(corePicks); + + // Step 3: merge the freshly updated core into plus. + checkout(PLUS_BRANCH); + log(`\n→ merge ${CORE_BRANCH} into ${PLUS_BRANCH}`); + if (!dryRun) { + try { + git(['merge', '--no-edit', CORE_BRANCH], { inherit: true }); + } catch { + git(['merge', '--abort'], { inherit: true }); + fail( + `Merge of ${CORE_BRANCH} into ${PLUS_BRANCH} failed (conflict). ` + + `Aborted the merge — resolve manually and re-run.`, + ); + } + } + + // Step 4: bring missing [PLUS] commits onto plus (same saved boundary). + const plusPicks = commitsToCherryPick(boundary, DEV_BRANCH, PLUS_PREFIX); + log( + `\nFound ${plusPicks.length} "${PLUS_PREFIX}" commit(s) in ` + + `${DEV_BRANCH} since last ${PLUS_BRANCH} release.`, + ); + cherryPickAll(plusPicks); + + // Step 5: rebase dev onto the freshly built plus. + log(`\n→ rebase ${DEV_BRANCH} onto ${PLUS_BRANCH} (rewrites ${DEV_BRANCH})`); + const proceed = await confirm( + `This will force-rewrite "${DEV_BRANCH}". Continue?`, + ); + if (!proceed) { + fail('Aborted before rebasing dev. core/plus changes are kept.'); + } + checkout(DEV_BRANCH); + if (!dryRun) { + try { + git(['rebase', PLUS_BRANCH], { inherit: true }); + } catch { + git(['rebase', '--abort'], { inherit: true }); + fail( + `Rebase of ${DEV_BRANCH} onto ${PLUS_BRANCH} failed (conflict). ` + + `Aborted the rebase — resolve manually and re-run.`, + ); + } + } + + if (dryRun) { + checkout(startBranch); + log('\nDry run complete. Re-run without --dry-run to apply.'); + } else { + log( + `\n✓ Release complete. ${DEV_BRANCH} now sits on top of ${PLUS_BRANCH}.`, + ); + log( + ` Push when ready: git push origin ${CORE_BRANCH} ${PLUS_BRANCH} ` + + `&& git push --force-with-lease origin ${DEV_BRANCH}`, + ); + } +} + +main().catch(function onError(error) { + fail(error.message || String(error)); +});