Auto versioning (#1344)

* feat: implement version bumping script and pre-push hook

* chore: bump version to 1.113.5

* chore: bump version to 1.113.6

* chore: bump version to 1.113.7

* chore: bump version to 1.113.8

* chore: bump version to 1.113.9

* chore: bump version to 1.113.10

* chore: bump version to 1.113.3 and update versioning process

* chore: enhance version bump process with base version check

* Update .github/workflows/bump-version.yml

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update .github/workflows/bump-version.yml

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix: sync package-lock.json version fields during automated version bump (#1345)

* Initial plan

* fix: update package-lock.json version fields during version bump

Co-authored-by: Azgaar <26469650+Azgaar@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Azgaar <26469650+Azgaar@users.noreply.github.com>

* Fix branch name in versioning.js comment: 'main' → 'master' (#1346)

* Initial plan

* fix: update branch name in versioning.js comment from 'main' to 'master'

Co-authored-by: Azgaar <26469650+Azgaar@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Azgaar <26469650+Azgaar@users.noreply.github.com>

* Extend bump-version.js to update ?v= cache-busting hashes in public/**/*.js dynamic imports (#1347)

* Initial plan

* feat: extend bump-version.js to update ?v= hashes in public/**/*.js dynamic imports

Co-authored-by: Azgaar <26469650+Azgaar@users.noreply.github.com>

* chore: merge base branch changes (package-lock.json sync, RELEASE_BOT_TOKEN, node 24.x, comment fix)

Co-authored-by: Azgaar <26469650+Azgaar@users.noreply.github.com>

* Update scripts/bump-version.js

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update scripts/bump-version.js

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Azgaar <26469650+Azgaar@users.noreply.github.com>
Co-authored-by: Azgaar <maxganiev@yandex.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* refactor: streamline dynamic import hash updates for public JS files

* refactor: enhance version bump detection using AI analysis of PR diffs

* Auto versioning (#1348)

* Initial plan

* fix: add ref to checkout step and stage public/**/*.js in bump workflow

Co-authored-by: Azgaar <26469650+Azgaar@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Azgaar <26469650+Azgaar@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Azgaar <26469650+Azgaar@users.noreply.github.com>
This commit is contained in:
Azgaar 2026-03-07 18:15:18 +01:00 committed by GitHub
parent a66b60b5a7
commit d7555ff1b2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 601 additions and 19 deletions

83
.github/workflows/bump-version.yml vendored Normal file
View file

@ -0,0 +1,83 @@
name: Bump version on PR merge
on:
pull_request:
branches: [master]
types: [closed]
permissions:
contents: write
jobs:
bump:
# Only run when the PR was actually merged (not just closed)
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
with:
# Check out the base branch (master) so we are on a real branch, not
# a detached PR merge ref, which makes git push work without arguments.
ref: ${{ github.event.pull_request.base.ref }}
# fetch-depth 2 so HEAD~1 resolves to the pre-merge master commit
fetch-depth: 2
# Use a PAT so the pushed bump commit can trigger deploy.yml
token: ${{ secrets.RELEASE_BOT_TOKEN }}
- name: Set up Node
uses: actions/setup-node@v6
with:
node-version: "24.x"
cache: npm
- name: Get PR diff
run: git diff HEAD~1 HEAD > /tmp/pr.diff
- name: AI-detect bump type from diff
id: bump
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TYPE=$(node scripts/detect-bump-type.js --diff-file /tmp/pr.diff)
echo "type=$TYPE" >> "$GITHUB_OUTPUT"
echo "AI-determined bump type: $TYPE"
- name: Extract base version (master before this PR merged)
id: base
run: |
BASE=$(git show HEAD~1:public/versioning.js \
| grep -oP 'const VERSION = "\K[\d.]+')
echo "version=$BASE" >> "$GITHUB_OUTPUT"
echo "Base version: $BASE"
- name: Run version bump script
run: |
node scripts/bump-version.js ${{ steps.bump.outputs.type }} \
--base-version ${{ steps.base.outputs.version }}
- name: Commit and push bump
run: |
NEW_VERSION=$(node -e "
const m = require('fs')
.readFileSync('public/versioning.js', 'utf8')
.match(/const VERSION = \"([\d.]+)\"/);
console.log(m[1]);
")
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
# Stage versioning files + any public/**/*.js whose dynamic import
# hashes may have been refreshed by updatePublicJsDynamicImportHashes
git add public/versioning.js package.json package-lock.json src/index.html
git add public/ 2>/dev/null || true
if ! git diff --cached --quiet; then
git commit -m "chore: bump version to $NEW_VERSION"
git push
echo "Pushed version bump → $NEW_VERSION"
else
echo "Nothing changed, skipping commit."
fi

10
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "fantasy-map-generator",
"version": "1.113.2",
"version": "1.113.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "fantasy-map-generator",
"version": "1.113.2",
"version": "1.113.3",
"license": "MIT",
"dependencies": {
"alea": "^1.0.1",
@ -1353,6 +1353,7 @@
"integrity": "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@ -1393,6 +1394,7 @@
"integrity": "sha512-gfajTHVCiwpxRj1qh0Sh/5bbGLG4F/ZH/V9xvFVoFddpITfMta9YGow0W6ZpTTORv2vdJuz9TnrNSmjKvpOf4g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/browser": "4.0.18",
"@vitest/mocker": "4.0.18",
@ -1874,6 +1876,7 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@ -2160,6 +2163,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -2471,6 +2475,7 @@
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@ -2546,6 +2551,7 @@
"integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/expect": "4.0.18",
"@vitest/mocker": "4.0.18",

View file

@ -606,4 +606,6 @@ dr_not_sam
Mie96
Riley
Amber Davis
tomtom1969vlbg`;
tomtom1969vlbg
Eric Knight
Adeline Lefizelier`;

View file

@ -5,15 +5,18 @@
* We use Semantic Versioning: major.minor.patch. Refer to https://semver.org
* Our .map file format is considered the public API.
*
* Update the version MANUALLY on each merge to main:
* Update the version on each merge to master:
* 1. MAJOR version: Incompatible changes that break existing maps
* 2. MINOR version: Additions or changes that are backward-compatible but may require old .map files to be updated
* 3. PATCH version: Backward-compatible bug fixes and small features that do not affect the .map file format
* 3. PATCH version: Backward-compatible bug fixes and small features that don't affect the .map file format
*
* Example: 1.102.2 -> Major version 1, Minor version 102, Patch version 2
* Version bumping is automated via GitHub Actions on PR merge.
*
* For the changes that may be interesting to end users, update the `latestPublicChanges` array below (new changes on top).
*/
const VERSION = "1.113.4";
const VERSION = "1.113.3";
if (parseMapVersion(VERSION) !== VERSION) alert("versioning.js: Invalid format or parsing function");
{
@ -26,6 +29,22 @@ if (parseMapVersion(VERSION) !== VERSION) alert("versioning.js: Invalid format o
setTimeout(showUpdateWindow, 6000);
}
const latestPublicChanges = [
"Search input in Overview dialogs",
"Custom burg grouping and icon selection",
"Ability to set custom image as Marker or Regiment icon",
"Submap and Transform tools rework",
"Azgaar Bot to answer questions and provide help",
"Labels: ability to set letter spacing",
"Zones performance improvement",
"Notes Editor: on-demand AI text generation",
"New style preset: Dark Seas",
"New routes generation algorithm",
"Routes overview tool",
"Configurable longitude",
"Export zones to GeoJSON"
];
function showUpdateWindow() {
const changelog = "https://github.com/Azgaar/Fantasy-Map-Generator/wiki/Changelog";
const reddit = "https://www.reddit.com/r/FantasyMapGenerator";
@ -37,19 +56,7 @@ if (parseMapVersion(VERSION) !== VERSION) alert("versioning.js: Invalid format o
<ul>
<strong>Latest changes:</strong>
<li>Search input in Overview dialogs</li>
<li>Custom burg grouping and icon selection</li>
<li>Ability to set custom image as Marker or Regiment icon</li>
<li>Submap and Transform tools rework</li>
<li>Azgaar Bot to answer questions and provide help</li>
<li>Labels: ability to set letter spacing</li>
<li>Zones performance improvement</li>
<li>Notes Editor: on-demand AI text generation</li>
<li>New style preset: Dark Seas</li>
<li>New routes generation algorithm</li>
<li>Routes overview tool</li>
<li>Configurable longitude</li>
<li>Export zones to GeoJSON</li>
${latestPublicChanges.map(change => `<li>${change}</li>`).join("")}
</ul>
<p>Join our <a href="${discord}" target="_blank">Discord server</a> and <a href="${reddit}" target="_blank">Reddit community</a> to ask questions, share maps, discuss the Generator and Worlbuilding, report bugs and propose new features.</p>

326
scripts/bump-version.js Normal file
View file

@ -0,0 +1,326 @@
#!/usr/bin/env node
"use strict";
/**
* Bump the project version (patch / minor / major).
*
* Updates:
* - public/versioning.js VERSION constant
* - package.json "version" field
* - package-lock.json top-level "version" and packages[""].version fields
* - src/index.html ?v= cache-busting hashes for changed public/*.js files
* - public/**\/*.js ?v= cache-busting hashes in dynamic import() calls
*
* Usage:
* node scripts/bump-version.js # interactive prompt
* node scripts/bump-version.js patch # non-interactive
* node scripts/bump-version.js minor # non-interactive
* node scripts/bump-version.js major # non-interactive
* node scripts/bump-version.js --dry-run # preview only, no writes
*/
const fs = require("fs");
const path = require("path");
const readline = require("readline");
const {execSync} = require("child_process");
// ---------------------------------------------------------------------------
// Paths
// ---------------------------------------------------------------------------
const repoRoot = path.resolve(__dirname, "..");
const packageJsonPath = path.join(repoRoot, "package.json");
const packageLockJsonPath = path.join(repoRoot, "package-lock.json");
const versioningPath = path.join(repoRoot, "public", "versioning.js");
const indexHtmlPath = path.join(repoRoot, "src", "index.html");
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function readFile(filePath) {
return fs.readFileSync(filePath, "utf8");
}
function writeFile(filePath, content) {
fs.writeFileSync(filePath, content, "utf8");
}
function parseCurrentVersion() {
const content = readFile(versioningPath);
const match = content.match(/const VERSION = "(\d+\.\d+\.\d+)";/);
if (!match) throw new Error("Could not find VERSION constant in public/versioning.js");
return match[1];
}
function bumpVersion(version, type) {
const [major, minor, patch] = version.split(".").map(Number);
if (type === "major") return `${major + 1}.0.0`;
if (type === "minor") return `${major}.${minor + 1}.0`;
return `${major}.${minor}.${patch + 1}`;
}
function escapeRegExp(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/** Returns true if versionA is strictly greater than versionB (semver). */
function isVersionGreater(versionA, versionB) {
const a = versionA.split(".").map(Number);
const b = versionB.split(".").map(Number);
for (let i = 0; i < 3; i++) {
if (a[i] > b[i]) return true;
if (a[i] < b[i]) return false;
}
return false; // equal
}
/**
* Returns public/*.js paths (relative to repo root) that have changed.
* Checks (in order, deduplicating):
* 1. Upstream branch diff catches everything on a feature/PR branch
* 2. Staged (index) diff catches files staged but not yet committed
* 3. Last-commit diff fallback for main / detached HEAD
*/
function getChangedPublicJsFiles() {
const run = cmd => execSync(cmd, {encoding: "utf8", cwd: repoRoot});
const parseFiles = output =>
output
.split("\n")
.map(f => f.trim())
.filter(f => f.startsWith("public/") && f.endsWith(".js"));
const seen = new Set();
const collect = files => files.forEach(f => seen.add(f));
// 1. Upstream branch diff
try {
const upstream = run("git rev-parse --abbrev-ref --symbolic-full-name @{upstream}").trim();
collect(parseFiles(run(`git diff --name-only ${upstream}...HEAD`)));
} catch {
/* no upstream */
}
// 2. Staged changes (useful when building before committing)
try {
collect(parseFiles(run("git diff --name-only --cached")));
} catch {
/* ignore */
}
if (seen.size > 0) return [...seen];
// 3. Fallback: last commit diff
try {
return parseFiles(run("git diff --name-only HEAD~1 HEAD"));
} catch {
/* shallow / single-commit repo */
}
return [];
}
// ---------------------------------------------------------------------------
// File updaters
// ---------------------------------------------------------------------------
function updateVersioningJs(newVersion, dry) {
const original = readFile(versioningPath);
const updated = original.replace(/const VERSION = "\d+\.\d+\.\d+";/, `const VERSION = "${newVersion}";`);
if (original === updated) throw new Error("Failed to update VERSION in public/versioning.js");
if (!dry) writeFile(versioningPath, updated);
console.log(` public/versioning.js → ${newVersion}`);
}
function updatePackageJson(newVersion, dry) {
const original = readFile(packageJsonPath);
const pkg = JSON.parse(original);
const oldVersion = pkg.version;
pkg.version = newVersion;
if (!dry) writeFile(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`);
console.log(` package.json ${oldVersion}${newVersion}`);
}
function updatePackageLockJson(newVersion, dry) {
if (!fs.existsSync(packageLockJsonPath)) {
console.log(" package-lock.json (not found, skipping)");
return;
}
const original = readFile(packageLockJsonPath);
const lock = JSON.parse(original);
const oldVersion = lock.version;
lock.version = newVersion;
if (lock.packages && lock.packages[""]) {
lock.packages[""].version = newVersion;
}
if (!dry) writeFile(packageLockJsonPath, `${JSON.stringify(lock, null, 2)}\n`);
console.log(` package-lock.json ${oldVersion}${newVersion}`);
}
function updateIndexHtmlHashes(changedFiles, newVersion, dry) {
if (changedFiles.length === 0) {
console.log(" src/index.html (no changed public/*.js files detected)");
return;
}
let html = readFile(indexHtmlPath);
const updated = [];
for (const publicPath of changedFiles) {
const htmlPath = publicPath.replace(/^public\//, "");
const pattern = new RegExp(`${escapeRegExp(htmlPath)}\\?v=[0-9.]+`, "g");
if (pattern.test(html)) {
html = html.replace(pattern, `${htmlPath}?v=${newVersion}`);
updated.push(htmlPath);
}
}
if (updated.length > 0) {
if (!dry) writeFile(indexHtmlPath, html);
console.log(` src/index.html hashes updated for:\n - ${updated.join("\n - ")}`);
} else {
console.log(
` src/index.html (changed files have no ?v= entry: ${changedFiles.map(f => f.replace("public/", "")).join(", ")}`
);
}
}
/**
* For each changed public JS file, scans ALL other public JS files for
* dynamic import() calls that reference it via a relative ?v= path, and
* updates the hash to newVersion.
*
* Example: public/modules/dynamic/installation.js changed
* main.js: import("./modules/dynamic/installation.js?v=1.89.19")
* import("./modules/dynamic/installation.js?v=1.113.4")
*/
function updatePublicJsDynamicImportHashes(changedFiles, newVersion, dry) {
if (changedFiles.length === 0) {
console.log(" public/**/*.js (no changed files, skipping dynamic import hashes)");
return;
}
// Absolute paths of every changed file for O(1) lookup
const changedAbsPaths = new Set(changedFiles.map(f => path.join(repoRoot, f)));
// Collect all public JS files, skipping public/libs (third-party)
const publicRoot = path.join(repoRoot, "public");
const allJsFiles = [];
(function walk(dir) {
for (const entry of fs.readdirSync(dir, {withFileTypes: true})) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (path.relative(publicRoot, full).replace(/\\/g, "/") === "libs") continue;
walk(full);
} else if (entry.isFile() && entry.name.endsWith(".js")) {
allJsFiles.push(full);
}
}
})(publicRoot);
const updatedMap = {};
for (const absJsFile of allJsFiles) {
const content = readFile(absJsFile);
// Matches: import("../path/file.js?v=1.2.3") or import('../path/file.js?v=1.2.3')
const pattern = /(['"])(\.{1,2}\/[^'"?]+)\?v=[0-9.]+\1/g;
let anyChanged = false;
const newContent = content.replace(pattern, (match, quote, relImportPath) => {
const absImport = path.resolve(path.dirname(absJsFile), relImportPath);
if (!changedAbsPaths.has(absImport)) return match;
const repoRelFile = path.relative(repoRoot, absJsFile).replace(/\\/g, "/");
if (!updatedMap[repoRelFile]) updatedMap[repoRelFile] = [];
updatedMap[repoRelFile].push(relImportPath);
anyChanged = true;
return `${quote}${relImportPath}?v=${newVersion}${quote}`;
});
if (anyChanged && !dry) writeFile(absJsFile, newContent);
}
if (Object.keys(updatedMap).length > 0) {
const lines = Object.entries(updatedMap)
.map(([file, refs]) => ` ${file}:\n - ${refs.join("\n - ")}`)
.join("\n");
console.log(` public/**/*.js dynamic import hashes updated:\n${lines}`);
} else {
console.log(" public/**/*.js (no dynamic import ?v= hashes to update)");
}
}
// ---------------------------------------------------------------------------
// Prompt
// ---------------------------------------------------------------------------
function promptBumpType(currentVersion) {
return new Promise(resolve => {
const rl = readline.createInterface({input: process.stdin, output: process.stdout});
process.stdout.write(`\nCurrent version: ${currentVersion}\nBump type (patch / minor / major) [patch]: `);
rl.once("line", answer => {
rl.close();
const input = answer.trim().toLowerCase();
if (input === "minor" || input === "mi") return resolve("minor");
if (input === "major" || input === "maj") return resolve("major");
resolve("patch");
});
});
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
const argv = process.argv.slice(2);
const args = argv.map(a => a.toLowerCase());
const dry = args.includes("--dry-run");
// --base-version X.Y.Z — version on master before this PR was merged.
// When provided, the script checks whether the developer already bumped
// the version manually in their branch. If so, the increment is skipped
// and only the ?v= hashes in index.html are refreshed.
const baseVersionFlagIdx = argv.findIndex(a => a === "--base-version");
const baseVersion = baseVersionFlagIdx !== -1 ? argv[baseVersionFlagIdx + 1] : null;
if (dry) console.log("\n[bump-version] DRY RUN — no files will be changed\n");
const currentVersion = parseCurrentVersion();
if (baseVersion && isVersionGreater(currentVersion, baseVersion)) {
// Developer already bumped the version manually in their branch.
console.log(
`\n[bump-version] Version already updated manually: ${baseVersion}${currentVersion} (base was ${baseVersion})\n`
);
console.log(" Skipping version increment — updating ?v= hashes only.\n");
const changedFiles = getChangedPublicJsFiles();
updateIndexHtmlHashes(changedFiles, currentVersion, dry);
updatePublicJsDynamicImportHashes(changedFiles, currentVersion, dry);
console.log(`\n[bump-version] ${dry ? "(dry run) " : ""}done.\n`);
return;
}
// Determine bump type: CLI arg → stdin prompt → default patch
let bumpType;
if (args.includes("major")) bumpType = "major";
else if (args.includes("minor")) bumpType = "minor";
else if (args.includes("patch")) bumpType = "patch";
else if (process.stdin.isTTY) bumpType = await promptBumpType(currentVersion);
else bumpType = "patch"; // non-interactive (CI / pipe)
const newVersion = bumpVersion(currentVersion, bumpType);
console.log(`\n[bump-version] ${bumpType}: ${currentVersion}${newVersion}\n`);
const changedFiles = getChangedPublicJsFiles();
updateVersioningJs(newVersion, dry);
updatePackageJson(newVersion, dry);
updatePackageLockJson(newVersion, dry);
updateIndexHtmlHashes(changedFiles, newVersion, dry);
updatePublicJsDynamicImportHashes(changedFiles, newVersion, dry);
console.log(`\n[bump-version] ${dry ? "(dry run) " : ""}done.\n`);
}
main().catch(err => {
console.error("\n[bump-version] Error:", err.message || err);
process.exit(1);
});

158
scripts/detect-bump-type.js Normal file
View file

@ -0,0 +1,158 @@
#!/usr/bin/env node
"use strict";
/**
* Uses the GitHub Models API (gpt-4o-mini, no extra secrets required
* GITHUB_TOKEN is enough when running inside GitHub Actions) to analyse
* a PR diff and decide whether the release is a patch, minor, or major bump.
*
* Versioning rules (from public/versioning.js):
* MAJOR incompatible changes that break existing .map files
* MINOR backward-compatible additions or changes that may require
* old .map files to be updated / migrated
* PATCH backward-compatible bug fixes and small features that do
* NOT affect the .map file format at all
*
* Usage (called by bump-version.yml):
* node scripts/detect-bump-type.js --diff-file <path>
*
* Output: prints exactly one of: patch | minor | major
* Exit 0 always (falls back to "patch" on any error).
*/
const fs = require("fs");
const https = require("https");
const path = require("path");
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
const MODEL = "gpt-4o-mini";
const ENDPOINT_HOST = "models.inference.ai.azure.com";
const ENDPOINT_PATH = "/chat/completions";
// Keep the diff under ~20 000 chars to stay within the model's context window.
const MAX_DIFF_CHARS = 20_000;
// ---------------------------------------------------------------------------
// System prompt — contains the project-specific versioning rules
// ---------------------------------------------------------------------------
const SYSTEM_PROMPT = `\
You are a semantic-version expert for Azgaar's Fantasy Map Generator.
The project uses semantic versioning where the PUBLIC API is the .map save-file format.
Rules:
MAJOR any change that makes existing .map files incompatible or unreadable
(e.g. removing or renaming top-level save-data fields, changing data types of
stored values, restructuring the save format)
MINOR backward-compatible additions or changes that may require old .map
files to be silently migrated on load (e.g. adding new optional fields to the
save format, changing default values that affect saved maps, adding new
generators that store new data)
PATCH everything else: UI improvements, bug fixes, refactors, new features
that do not touch the .map file format at all, dependency updates, docs
Respond with EXACTLY one lowercase word: patch | minor | major
No explanation, no punctuation, no extra words.`;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function httpsPost(host, pathStr, headers, body) {
return new Promise((resolve, reject) => {
const data = JSON.stringify(body);
const req = https.request(
{host, path: pathStr, method: "POST", headers: {...headers, "Content-Length": Buffer.byteLength(data)}},
res => {
let raw = "";
res.on("data", c => (raw += c));
res.on("end", () => {
if (res.statusCode >= 200 && res.statusCode < 300) resolve(raw);
else reject(new Error(`HTTP ${res.statusCode}: ${raw.slice(0, 300)}`));
});
}
);
req.on("error", reject);
req.write(data);
req.end();
});
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
const args = process.argv.slice(2);
const diffFileIdx = args.indexOf("--diff-file");
const diffFile = diffFileIdx !== -1 ? args[diffFileIdx + 1] : null;
if (!diffFile || !fs.existsSync(diffFile)) {
console.error("[detect-bump-type] --diff-file <path> is required and must exist.");
process.stdout.write("patch\n");
process.exit(0);
}
const token = process.env.GITHUB_TOKEN;
if (!token) {
console.error("[detect-bump-type] GITHUB_TOKEN not set — falling back to patch.");
process.stdout.write("patch\n");
process.exit(0);
}
let diff = fs.readFileSync(diffFile, "utf8").trim();
if (!diff) {
console.error("[detect-bump-type] Diff is empty — falling back to patch.");
process.stdout.write("patch\n");
process.exit(0);
}
// Trim diff to avoid exceeding context window
if (diff.length > MAX_DIFF_CHARS) {
diff = diff.slice(0, MAX_DIFF_CHARS) + "\n\n[diff truncated]";
console.error(`[detect-bump-type] Diff truncated to ${MAX_DIFF_CHARS} chars.`);
}
const userMessage = `Analyse this git diff and respond with exactly one word (patch, minor, or major):\n\n${diff}`;
try {
const raw = await httpsPost(
ENDPOINT_HOST,
ENDPOINT_PATH,
{
"Content-Type": "application/json",
Authorization: `Bearer ${token}`
},
{
model: MODEL,
messages: [
{role: "system", content: SYSTEM_PROMPT},
{role: "user", content: userMessage}
],
temperature: 0,
max_tokens: 5
}
);
const json = JSON.parse(raw);
const answer = json.choices?.[0]?.message?.content?.trim().toLowerCase() ?? "patch";
if (answer === "major" || answer === "minor" || answer === "patch") {
console.error(`[detect-bump-type] AI decision: ${answer}`);
process.stdout.write(`${answer}\n`);
} else {
console.error(`[detect-bump-type] Unexpected AI response "${answer}" — defaulting to patch.`);
process.stdout.write("patch\n");
}
} catch (err) {
console.error(`[detect-bump-type] API error: ${err.message} — falling back to patch.`);
process.stdout.write("patch\n");
}
process.exit(0);
}
main();