diff --git a/.gitignore b/.gitignore index f1868a0..2597e4f 100755 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ npm-debug.log .DS_Store .idea .vscode +tools/release/config.json diff --git a/package.json b/package.json index b7efad3..07204fb 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fredy", - "version": "19.3.6", + "version": "19.3.7", "description": "[F]ind [R]eal [E]states [d]amn eas[y].", "scripts": { "prepare": "husky", @@ -17,7 +17,8 @@ "lint:fix": "yarn lint --fix", "migratedb": "node lib/services/storage/migrations/migrate.js", "migratedb:overwrite": "x-var MIGRATION_ALLOW_CHECKSUM_UPDATE=true node lib/services/storage/migrations/migrate.js", - "copyright": "node ./copyright.js" + "copyright": "node ./copyright.js", + "release": "node ./tools/release/release.js" }, "type": "module", "lint-staged": { @@ -98,6 +99,7 @@ "zustand": "^5.0.11" }, "devDependencies": { + "chalk": "^5.6.2", "@babel/core": "7.29.0", "@babel/eslint-parser": "7.28.6", "@babel/preset-env": "7.29.0", diff --git a/tools/release/release.js b/tools/release/release.js new file mode 100644 index 0000000..8d4c341 --- /dev/null +++ b/tools/release/release.js @@ -0,0 +1,196 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +import fs from 'fs'; +import path from 'path'; +import { execSync, spawn } from 'child_process'; +import fetch from 'node-fetch'; +import chalk from 'chalk'; +import { fileURLToPath } from 'url'; + +/** + * Release Tool for Fredy + * + * This tool automates the process of creating a GitHub release. + * It fetches the latest release, compares it with the current master branch, + * allows manual editing of commit messages, and creates a new release on GitHub. + */ + +// Define __dirname for ESM +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// Configuration and Paths +const CONFIG_PATH = path.join(__dirname, 'config.json'); +const PACKAGE_JSON_PATH = path.join(__dirname, '../../package.json'); +const REPO = 'orangecoding/fredy'; +const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')); +const GITHUB_TOKEN = config.github_token; + +/** + * Main function to execute the release process + */ +async function createRelease() { + /* eslint-disable no-console */ + try { + console.log(chalk.cyan('🚀 Starting release process...')); + + // 1. Load Configuration + if (!fs.existsSync(CONFIG_PATH)) { + console.error(chalk.red('❌ Error: config.json not found in tools/release/')); + process.exit(1); + } + + if (!GITHUB_TOKEN) { + console.error(chalk.red('❌ Error: GitHub token not configured.')); + process.exit(1); + } + + // 2. Get current version from package.json + const packageJson = JSON.parse(fs.readFileSync(PACKAGE_JSON_PATH, 'utf8')); + const version = packageJson.version; + const tag = version; // Using version as tag + + console.log(chalk.blue(`📦 Target version: ${version}`)); + + // 3. Check if release already exists + console.log(chalk.yellow('🔍 Checking if release already exists...')); + const existingReleaseResponse = await fetch(`https://api.github.com/repos/${REPO}/releases/tags/${tag}`, { + headers: { + Authorization: `token ${GITHUB_TOKEN}`, + Accept: 'application/vnd.github.v3+json', + }, + }); + + if (existingReleaseResponse.status === 200) { + console.error(chalk.red(`❌ Error: A release with tag ${tag} already exists.`)); + process.exit(1); + } + + // 4. Fetch latest release to find the starting point for the diff + console.log(chalk.yellow('📡 Fetching latest release from GitHub...')); + const latestReleaseResponse = await fetch(`https://api.github.com/repos/${REPO}/releases/latest`, { + headers: { + Authorization: `token ${GITHUB_TOKEN}`, + Accept: 'application/vnd.github.v3+json', + }, + }); + + if (!latestReleaseResponse.ok) { + console.error(chalk.red('❌ Error fetching latest release.')); + const errorData = await latestReleaseResponse.json(); + console.error(chalk.red(JSON.stringify(errorData))); + process.exit(1); + } + + const latestRelease = await latestReleaseResponse.json(); + const latestTag = latestRelease.tag_name; + console.log(chalk.green(`✅ Latest release found: ${latestTag}`)); + + // 5. Ensure the latest tag is available locally + console.log(chalk.yellow(`📡 Fetching tag ${latestTag} from remote...`)); + try { + execSync(`git fetch origin tag ${latestTag} --no-tags`); + } catch (error) { + console.error(chalk.red(`❌ Error fetching tag ${latestTag} from origin.`)); + console.error(error.message); + // We don't exit here, maybe it's already there but fetch failed for some reason + } + + // 6. Get commit messages between latest tag and current HEAD + console.log(chalk.yellow(`Git diff: ${latestTag} .. HEAD`)); + let commitMessages; + try { + commitMessages = execSync(`git log ${latestTag}..HEAD --pretty=format:"- %s"`).toString().trim(); + } catch (error) { + console.error(chalk.red('❌ Error running git log. Make sure the latest tag is available locally.'), error); + process.exit(1); + } + + if (!commitMessages) { + console.log(chalk.magenta('⚠️ No new commits found since last release.')); + commitMessages = '- No changes recorded'; + } + + // 7. Open commit messages in editor for manual adjustment + const tempFilePath = path.join(__dirname, 'CHANGELOG_EDIT.tmp'); + const initialContent = `# Release Notes for ${version}\n# Edit the messages below. Lines starting with # will be ignored.\n\n${commitMessages}`; + fs.writeFileSync(tempFilePath, initialContent); + + console.log(chalk.blue('📝 Opening editor for release notes (using nano or $EDITOR)...')); + await openInEditor(tempFilePath); + + // 8. Read edited content + let editedContent = fs + .readFileSync(tempFilePath, 'utf8') + .split('\n') + .filter((line) => !line.startsWith('#')) + .join('\n') + .trim(); + + fs.unlinkSync(tempFilePath); // Clean up temp file + + if (!editedContent) { + console.error(chalk.red('❌ Release notes are empty. Aborting release.')); + process.exit(1); + } + + // 9. Create the new release + console.log(chalk.cyan(`🚀 Creating release ${version} on GitHub...`)); + const createResponse = await fetch(`https://api.github.com/repos/${REPO}/releases`, { + method: 'POST', + headers: { + Authorization: `token ${GITHUB_TOKEN}`, + Accept: 'application/vnd.github.v3+json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + tag_name: tag, + name: version, + body: editedContent, + draft: false, + prerelease: false, + }), + }); + + if (createResponse.status === 201) { + const data = await createResponse.json(); + console.log(chalk.green('🎉 Release successfully created!')); + console.log(chalk.green(`🔗 URL: ${data.html_url}`)); + } else { + const errorData = await createResponse.json(); + console.error(chalk.red('❌ Failed to create release.')); + console.error(chalk.red(JSON.stringify(errorData, null, 2))); + process.exit(1); + } + } catch (error) { + console.error(chalk.red('💥 An unexpected error occurred:')); + console.error(error); + process.exit(1); + } +} + +/** + * Helper to open a file in a terminal editor + * @param {string} filePath + */ +function openInEditor(filePath) { + return new Promise((resolve, reject) => { + const editor = process.env.EDITOR || 'nano'; + const child = spawn(editor, [filePath], { + stdio: 'inherit', + }); + + child.on('exit', (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`Editor exited with code ${code}`)); + } + }); + }); +} + +await createRelease(); +/* eslint-enable no-console */ diff --git a/yarn.lock b/yarn.lock index 10e226d..a3b3f58 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2401,6 +2401,11 @@ chalk@^4.0.0, chalk@^4.1.0: ansi-styles "^4.1.0" supports-color "^7.1.0" +chalk@^5.6.2: + version "5.6.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.6.2.tgz#b1238b6e23ea337af71c7f8a295db5af0c158aea" + integrity sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA== + character-entities-html4@^2.0.0: version "2.1.0" resolved "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz"