diff --git a/README.md b/README.md index d4155a7..4758845 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ Dev credentials: `dev` / `Test123` (if seed data has been applied). | `bun run payload mission-tick` | Auto-complete missions whose scheduled day has passed | | `bun run payload server-tick` | Mark game servers without a recent heartbeat offline | | `bun run deploy` | Bump patch version, build, and deploy | +| `bun run version:bump -- --base --head ` | Choose and apply a patch/minor bump from a git range | Note: `game-tick` and `market-tick` are Payload bins registered in `payload.config.ts`, not npm scripts. Run via `bun run payload`. @@ -67,6 +68,10 @@ All five bin scripts also log to daily files under `logs//` (e.g. `logs For an Arma 3 unit deployment we self-host on **Coolify** with one container per environment (dev / stg / prod), a Coolify-managed PostgreSQL service per environment, a persistent volume for `/app/media`, and `docker exec` scheduled jobs for the game tick. The Dockerfile is Bun-based (multi-stage, Next.js standalone runtime + Payload CLI kept at runtime), and `docker-compose.yml` mirrors the same shape for local full-stack dev. +### CI version bumps + +Run `bun run version:bump -- --base "$CI_PREVIOUS_SHA" --head "$CI_SHA"` before the production build. The script bumps minor for product changes under `src/app`, `src/components/frontend`, `src/lib`, `src/collections`, `src/bot`, `src/hooks`, `src/migrations`, or `src/utils`, and patch for other deployable changes. Use `--dry-run` to inspect the decision. Commit the changed `package.json` back to the deployment branch (for example, with a `[skip ci]` commit if your CI provider supports it); the existing `generate:version-info` build step will publish the new version to the app. + For the full setup — per-env Postgres provisioning, env vars, scheduled jobs, persistent media volume, SSE single-instance caveat, rollbacks, troubleshooting — see **[DEPLOYMENT.md](./DEPLOYMENT.md)**. ## Project structure diff --git a/package.json b/package.json index 7b671a5..86ecfe6 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "generate:importmap": "cross-env NODE_OPTIONS=--no-deprecation payload generate:importmap", "generate:types": "cross-env NODE_OPTIONS=--no-deprecation payload generate:types", "generate:version-info": "bun run src/scripts/generateVersionInfo.ts", + "version:bump": "bun run src/scripts/bumpVersion.ts", "lint": "cross-env NODE_OPTIONS=--no-deprecation next lint", "payload": "cross-env NODE_OPTIONS=--no-deprecation payload", "migrate": "bun run src/scripts/run-migrations.ts", diff --git a/src/scripts/bumpVersion.ts b/src/scripts/bumpVersion.ts new file mode 100644 index 0000000..095ef32 --- /dev/null +++ b/src/scripts/bumpVersion.ts @@ -0,0 +1,135 @@ +/** + * Bumps the application version for a deployable change. + * + * The script is intentionally dependency-free so it can run in CI before the + * production build. It changes package.json only; generated version metadata + * is refreshed by the existing generate:version-info script. + * + * Usage: + * bun run version:bump -- --base --head + * bun run version:bump -- --dry-run + */ +import { execFileSync } from "node:child_process"; +import { readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +type Bump = "patch" | "minor" | "none"; + +const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const packagePath = path.join(projectRoot, "package.json"); + +// Changes in these areas affect the product shipped to users and warrant a +// minor release. Everything else defaults to a patch release so every real +// production deployment still gets a distinct version. +const MINOR_PATHS = [ + "src/app/", + "src/bot/", + "src/collections/", + "src/components/frontend/", + "src/hooks/", + "src/lib/", + "src/migrations/", + "src/utils/", +]; + +const VERSION_ONLY_PATHS = new Set([ + "package.json", + "src/generated/versionInfo.json", +]); + +function git(args: string[]): string { + return execFileSync("git", args, { cwd: projectRoot, encoding: "utf8" }).trim(); +} + +function option(name: string): string | undefined { + const index = process.argv.indexOf(name); + return index === -1 ? undefined : process.argv[index + 1]; +} + +function hasFlag(name: string): boolean { + return process.argv.includes(name); +} + +function resolveRange(): [string, string] { + const head = option("--head") ?? process.env.GITHUB_SHA ?? "HEAD"; + const explicitBase = option("--base") ?? process.env.GITHUB_EVENT_BEFORE; + if (explicitBase && !/^0+$/.test(explicitBase)) return [explicitBase, head]; + + try { + return [git(["rev-parse", `${head}^`]), head]; + } catch { + // An initial commit has no parent. Comparing it to the empty tree makes + // the first deployment behave like any other deployable change. + return ["4b825dc642cb6eb9a060e54bf8d69288fbee4904", head]; + } +} + +function changedFiles(base: string, head: string): string[] { + return git(["diff", "--name-only", "--diff-filter=ACMRTUXB", base, head]) + .split("\n") + .map((file) => file.trim()) + .filter(Boolean); +} + +function commitSubjects(base: string, head: string): string[] { + return git(["log", "--format=%s", `${base}..${head}`]) + .split("\n") + .map((subject) => subject.trim()) + .filter(Boolean); +} + +export function chooseBump(files: string[], subjects: string[]): Bump { + const meaningfulFiles = files.filter((file) => !VERSION_ONLY_PATHS.has(file)); + if (meaningfulFiles.length === 0) return "none"; + + // Conventional commits provide an explicit signal when the changed paths + // alone do not capture the scope of a feature. + if (subjects.some((subject) => /^(feat|feature)(\(.+\))?!?:/i.test(subject))) { + return "minor"; + } + + return meaningfulFiles.some((file) => MINOR_PATHS.some((prefix) => file.startsWith(prefix))) + ? "minor" + : "patch"; +} + +function increment(version: string, bump: Exclude): string { + const match = /^(\d+)\.(\d+)\.(\d+)(?:-.+)?$/.exec(version); + if (!match) throw new Error(`package.json has an unsupported version: ${version}`); + + const [, major, minor, patch] = match; + return bump === "minor" + ? `${major}.${Number(minor) + 1}.0` + : `${major}.${minor}.${Number(patch) + 1}`; +} + +function main(): void { + const [base, head] = resolveRange(); + const files = changedFiles(base, head); + const subjects = commitSubjects(base, head); + const bump = chooseBump(files, subjects); + const packageJson = JSON.parse(readFileSync(packagePath, "utf8")) as { version?: string }; + + if (bump === "none") { + console.log("[version-bump] no product changes detected; version unchanged"); + return; + } + + if (typeof packageJson.version !== "string") { + throw new Error("package.json is missing a version"); + } + + const nextVersion = increment(packageJson.version, bump); + console.log(`[version-bump] ${bump}: ${packageJson.version} -> ${nextVersion}`); + console.log(`[version-bump] range: ${base}..${head}`); + console.log(`[version-bump] files: ${files.join(", ")}`); + + if (hasFlag("--dry-run")) return; + + packageJson.version = nextVersion; + writeFileSync(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`, "utf8"); + console.log(`[version-bump] updated ${path.relative(projectRoot, packagePath)}`); +} + +main();