diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml index 041f67c97..61665b823 100644 --- a/.forgejo/workflows/build.yml +++ b/.forgejo/workflows/build.yml @@ -35,12 +35,12 @@ jobs: SHORT_SHA=$(git rev-parse --short HEAD) echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT - - name: Cache Builder Binary + - name: Cache Builder Binaries id: cache-builder uses: actions/cache@v4 with: path: ./builder-bin - key: builder-v1-${{ runner.os }}-${{ hashFiles('src/actions/builder/**/*.rs', 'src/actions/builder/Cargo.toml', 'src/actions/builder/Cargo.lock') }} + key: builder-v2-${{ runner.os }}-${{ hashFiles('src/actions/builder/**/*.rs', 'src/actions/builder/Cargo.toml', 'src/actions/builder/Cargo.lock') }} - name: Install Rust if: steps.cache-builder.outputs.cache-hit != 'true' @@ -52,12 +52,15 @@ jobs: with: workspaces: "src/actions/builder -> target" - - name: Build Builder + - name: Build Binaries if: steps.cache-builder.outputs.cache-hit != 'true' run: | - cargo build --release --manifest-path src/actions/builder/Cargo.toml --bin builder + cargo build --release \ + --manifest-path src/actions/builder/Cargo.toml \ + --bin builder --bin minify-json mkdir -p ./builder-bin cp src/actions/builder/target/release/builder ./builder-bin/builder + cp src/actions/builder/target/release/minify-json ./builder-bin/minify-json - name: Cache Go Binaries id: cache-go @@ -88,11 +91,71 @@ jobs: restore-keys: | packwiz-cache-${{ runner.os }}- + - name: Minify JSON configs + run: | + set -eu + chmod +x ./builder-bin/minify-json + + if [ -d datapacks ]; then + for pack_dir in datapacks/*/; do + [ -d "$pack_dir" ] || continue + TARGET="${pack_dir}content" + if [ -d "$TARGET" ]; then + cp -r "$TARGET" "${TARGET}.original" + ./builder-bin/minify-json "$TARGET" + fi + done + fi + + if [ -d modpacks ]; then + for pack_dir in modpacks/*/; do + [ -d "$pack_dir" ] || continue + for subdir in "$pack_dir"*-mr "$pack_dir"*-cf; do + [ -d "$subdir" ] || continue + CONFIG_DIR="$subdir/config" + if [ -d "$CONFIG_DIR" ]; then + cp -r "$CONFIG_DIR" "${CONFIG_DIR}.original" + ./builder-bin/minify-json "$CONFIG_DIR" + fi + done + done + fi + - name: Run Build run: | chmod +x ./builder-bin/builder ./builder-bin/builder "${{ steps.meta.outputs.short_sha }}" + - name: Restore JSON sources + if: always() + run: | + set -eu + + if [ -d datapacks ]; then + for pack_dir in datapacks/*/; do + [ -d "$pack_dir" ] || continue + TARGET="${pack_dir}content" + if [ -d "${TARGET}.original" ]; then + rm -rf "$TARGET" + mv "${TARGET}.original" "$TARGET" + fi + done + fi + + if [ -d modpacks ]; then + for pack_dir in modpacks/*/; do + [ -d "$pack_dir" ] || continue + for subdir in "$pack_dir"*-mr "$pack_dir"*-cf; do + [ -d "$subdir" ] || continue + CONFIG_DIR="$subdir/config" + if [ -d "${CONFIG_DIR}.original" ]; then + rm -rf "$CONFIG_DIR" + mv "${CONFIG_DIR}.original" "$CONFIG_DIR" + fi + done + done + fi + - name: Upload uses: https://code.forgejo.org/actions/upload-artifact@v3 with: diff --git a/.forgejo/workflows/publish.yml b/.forgejo/workflows/publish.yml index f258daf36..2b7a05110 100644 --- a/.forgejo/workflows/publish.yml +++ b/.forgejo/workflows/publish.yml @@ -73,13 +73,18 @@ jobs: modpacks datapacks src/actions/publish + src/actions/builder tools/changelog + tools/manifest - name: Set up Node uses: actions/setup-node@v4 with: node-version: '20' + - name: Validate Manifest + run: npx tsx tools/manifest/validate.ts "${{ matrix.manifest }}" + - name: Generate Changelog id: changelog run: npx tsx tools/changelog/generate-changelog.ts "${{ matrix.manifest }}" @@ -89,18 +94,31 @@ jobs: uses: actions/cache@v4 with: path: ./publisher-bin - key: publisher-v1-${{ runner.os }}-${{ hashFiles('src/actions/publish/**/*.rs', 'src/actions/publish/Cargo.toml', 'src/actions/publish/Cargo.lock') }} + key: publisher-v2-${{ runner.os }}-${{ hashFiles('src/actions/publish/**/*.rs', 'src/actions/publish/Cargo.toml', 'src/actions/publish/Cargo.lock') }} + + - name: Cache Minify-JSON Binary + id: cache-minify + uses: actions/cache@v4 + with: + path: ./builder-bin + key: builder-v2-${{ runner.os }}-${{ hashFiles('src/actions/builder/**/*.rs', 'src/actions/builder/Cargo.toml', 'src/actions/builder/Cargo.lock') }} - name: Install Rust - if: steps.cache-publisher.outputs.cache-hit != 'true' + if: steps.cache-publisher.outputs.cache-hit != 'true' || steps.cache-minify.outputs.cache-hit != 'true' uses: https://github.com/dtolnay/rust-toolchain@stable - - name: Rust Cache + - name: Rust Cache (publish) if: steps.cache-publisher.outputs.cache-hit != 'true' uses: https://github.com/Swatinem/rust-cache@v2 with: workspaces: "src/actions/publish -> target" + - name: Rust Cache (builder) + if: steps.cache-minify.outputs.cache-hit != 'true' + uses: https://github.com/Swatinem/rust-cache@v2 + with: + workspaces: "src/actions/builder -> target" + - name: Build Publisher if: steps.cache-publisher.outputs.cache-hit != 'true' run: | @@ -108,6 +126,13 @@ jobs: mkdir -p ./publisher-bin cp src/actions/publish/target/release/publish ./publisher-bin/publish + - name: Build minify-json + if: steps.cache-minify.outputs.cache-hit != 'true' + run: | + cargo build --release --manifest-path src/actions/builder/Cargo.toml --bin minify-json + mkdir -p ./builder-bin + cp src/actions/builder/target/release/minify-json ./builder-bin/minify-json + - name: Cache Packwiz Binaries id: cache-go uses: actions/cache@v4 @@ -137,12 +162,61 @@ jobs: restore-keys: | packwiz-cache-${{ runner.os }}- + - name: Minify JSON configs + run: | + set -eu + chmod +x ./builder-bin/minify-json + + MANIFEST='${{ matrix.manifest }}' + PACK_DIR="$(dirname "$MANIFEST")" + + if [[ "$MANIFEST" == datapacks/* ]]; then + TARGET="${PACK_DIR}/content" + if [ -d "$TARGET" ]; then + cp -r "$TARGET" "${TARGET}.original" + ./builder-bin/minify-json "$TARGET" + fi + elif [[ "$MANIFEST" == modpacks/* ]]; then + for subdir in "$PACK_DIR"/*-mr "$PACK_DIR"/*-cf; do + [ -d "$subdir" ] || continue + CONFIG_DIR="$subdir/config" + if [ -d "$CONFIG_DIR" ]; then + cp -r "$CONFIG_DIR" "${CONFIG_DIR}.original" + ./builder-bin/minify-json "$CONFIG_DIR" + fi + done + fi + - name: Run Publisher id: meta run: | chmod +x ./publisher-bin/publish ./publisher-bin/publish "${{ matrix.manifest }}" + - name: Restore JSON sources + if: always() + run: | + set -eu + MANIFEST='${{ matrix.manifest }}' + PACK_DIR="$(dirname "$MANIFEST")" + + if [[ "$MANIFEST" == datapacks/* ]]; then + TARGET="${PACK_DIR}/content" + if [ -d "${TARGET}.original" ]; then + rm -rf "$TARGET" + mv "${TARGET}.original" "$TARGET" + fi + elif [[ "$MANIFEST" == modpacks/* ]]; then + for subdir in "$PACK_DIR"/*-mr "$PACK_DIR"/*-cf; do + [ -d "$subdir" ] || continue + CONFIG_DIR="$subdir/config" + if [ -d "${CONFIG_DIR}.original" ]; then + rm -rf "$CONFIG_DIR" + mv "${CONFIG_DIR}.original" "$CONFIG_DIR" + fi + done + fi + - name: Upload to Platforms if: "steps.meta.outputs.mr_id != '' || steps.meta.outputs.cf_id != ''" uses: https://github.com/Kir-Antipov/mc-publish@v3.3 @@ -155,6 +229,7 @@ jobs: curseforge-files: "${{ github.workspace }}/${{ steps.meta.outputs.path }}/artifacts/*.zip" name: "${{ steps.meta.outputs.name }}" version: "${{ steps.meta.outputs.ver }}" + version-type: ${{ steps.meta.outputs.release_type }} changelog: "${{ steps.changelog.outputs.notes }}" loaders: ${{ steps.meta.outputs.type == 'modpack' && steps.meta.outputs.loader || 'minecraft' }} game-versions: "${{ steps.meta.outputs.mc }}" \ No newline at end of file diff --git a/modpacks/rc-plus/manifest.json b/modpacks/rc-plus/manifest.json index 089d2b0e3..76fafce8d 100644 --- a/modpacks/rc-plus/manifest.json +++ b/modpacks/rc-plus/manifest.json @@ -1,9 +1,12 @@ { + "$schema": "../../tools/manifest/schema.json", "id": "rc-plus", "name": "Re-Console Plus", "type": "modpack", - "version": "26.04.7", + "loader": "fabric", "mc_version": "1.21.10", + "version": "26.04.8", + "release_type": "release", "modrinth_id": "legacy-minecraft", "curseforge_id": "re-console" -} +} \ No newline at end of file diff --git a/modpacks/simply/manifest.json b/modpacks/simply/manifest.json index b02a2b6b1..bffb68ed7 100644 --- a/modpacks/simply/manifest.json +++ b/modpacks/simply/manifest.json @@ -1,9 +1,12 @@ { + "$schema": "../../tools/manifest/schema.json", "id": "simply", "name": "Simply Legacy", "type": "modpack", - "version": "26.04.5", + "loader": "fabric", "mc_version": "1.21.10", + "version": "26.04.6", + "release_type": "release", "modrinth_id": "simply-legacy", "curseforge_id": "simply-legacy" -} +} \ No newline at end of file diff --git a/src/actions/builder/Cargo.toml b/src/actions/builder/Cargo.toml index b4ca45678..7e8c51690 100644 --- a/src/actions/builder/Cargo.toml +++ b/src/actions/builder/Cargo.toml @@ -13,3 +13,7 @@ zip = "2.2" [[bin]] name = "builder" path = "builder.rs" + +[[bin]] +name = "minify-json" +path = "minify-json.rs" \ No newline at end of file diff --git a/src/actions/builder/minify-json.rs b/src/actions/builder/minify-json.rs new file mode 100644 index 000000000..7f3d7b57d --- /dev/null +++ b/src/actions/builder/minify-json.rs @@ -0,0 +1,84 @@ +use anyhow::{anyhow, Context, Result}; +use std::{ + env, + fs, + path::Path, + sync::atomic::{AtomicU64, Ordering}, +}; +use walkdir::WalkDir; + +fn main() -> Result<()> { + let args: Vec = env::args().collect(); + let root = args + .get(1) + .ok_or_else(|| anyhow!("usage: minify-json "))?; + + let root_path = Path::new(root); + if !root_path.is_dir() { + return Err(anyhow!("not a directory: {root}")); + } + + let bytes_before = AtomicU64::new(0); + let bytes_after = AtomicU64::new(0); + let mut processed = 0usize; + let mut skipped = 0usize; + + for entry in WalkDir::new(root_path) { + let entry = entry.context("walkdir error")?; + let path = entry.path(); + if !path.is_file() { + continue; + } + + let ext = path.extension().and_then(|s| s.to_str()).unwrap_or(""); + if ext != "json" && ext != "mcmeta" { + continue; + } + + match minify_file(path) { + Ok((before, after)) => { + bytes_before.fetch_add(before, Ordering::Relaxed); + bytes_after.fetch_add(after, Ordering::Relaxed); + processed += 1; + } + Err(e) => { + eprintln!("warning: skipped {}: {e}", path.display()); + skipped += 1; + } + } + } + + let before = bytes_before.load(Ordering::Relaxed); + let after = bytes_after.load(Ordering::Relaxed); + let saved = before.saturating_sub(after); + let pct = if before > 0 { + (saved as f64 / before as f64) * 100.0 + } else { + 0.0 + }; + + println!( + "minified {processed} file(s), skipped {skipped}, saved {saved} bytes ({pct:.1}%)" + ); + Ok(()) +} + +fn minify_file(path: &Path) -> Result<(u64, u64)> { + let content = fs::read_to_string(path) + .with_context(|| format!("read failed: {}", path.display()))?; + let before = content.len() as u64; + + let value: serde_json::Value = serde_json::from_str(&content) + .with_context(|| format!("parse failed: {}", path.display()))?; + + let minified = serde_json::to_string(&value) + .with_context(|| format!("serialize failed: {}", path.display()))?; + let after = minified.len() as u64; + + if after < before { + fs::write(path, minified) + .with_context(|| format!("write failed: {}", path.display()))?; + } + + Ok((before, after)) +} diff --git a/src/actions/publish/publish.rs b/src/actions/publish/publish.rs index 00ed7bfeb..6d90124a9 100644 --- a/src/actions/publish/publish.rs +++ b/src/actions/publish/publish.rs @@ -16,8 +16,6 @@ enum Platform { } impl Platform { - const ALL: [Platform; 2] = [Platform::Modrinth, Platform::Curseforge]; - fn short(self) -> &'static str { match self { Platform::Modrinth => "mr", @@ -60,10 +58,15 @@ fn main() -> Result<()> { let p_ver = required_str(&manifest, "version")?; let mc_ver = required_str(&manifest, "mc_version")?; let p_type = required_str(&manifest, "type")?; - let loader = manifest["loader"].as_str().unwrap_or("fabric"); + let loader = required_str(&manifest, "loader")?; + let release_type = required_str(&manifest, "release_type")?; let mr_id = manifest["modrinth_id"].as_str().unwrap_or(""); let cf_id = manifest["curseforge_id"].as_str().unwrap_or(""); + if mr_id.is_empty() && cf_id.is_empty() { + bail!("manifest must set at least one of modrinth_id or curseforge_id"); + } + let workspace = env::var("GITHUB_WORKSPACE").unwrap_or_else(|_| ".".into()); let artifacts_dir = Path::new(&workspace).join(p_dir).join("artifacts"); if artifacts_dir.exists() { @@ -76,7 +79,7 @@ fn main() -> Result<()> { println!("::group::Building artifacts for {raw_name}"); match p_type { - "modpack" => build_modpack(p_dir, &artifacts_dir, &p_name, mc_ver, p_ver, loader)?, + "modpack" => build_modpack(p_dir, &artifacts_dir, &p_name, mc_ver, p_ver, loader, mr_id, cf_id)?, "datapack" => build_datapack(p_dir, &artifacts_dir, &manifest, p_ver)?, other => bail!("unsupported pack type: {other}"), } @@ -91,6 +94,7 @@ fn main() -> Result<()> { mc_ver, p_type, loader, + release_type, p_dir, })?; @@ -110,11 +114,16 @@ fn build_modpack( mc_ver: &str, p_ver: &str, loader: &str, + mr_id: &str, + cf_id: &str, ) -> Result<()> { let filename_base = format!("{p_name}-{mc_ver}-{loader}-{p_ver}"); let mut jobs: Vec<(Platform, PathBuf)> = Vec::new(); - for platform in Platform::ALL { + for (platform, id) in [(Platform::Modrinth, mr_id), (Platform::Curseforge, cf_id)] { + if id.is_empty() { + continue; + } let target_folder = format!("{mc_ver}-{}", platform.short()); let target_path = p_dir.join(&target_folder); if target_path.exists() { @@ -129,7 +138,7 @@ fn build_modpack( } if jobs.is_empty() { - bail!("no platform folders (mc_ver-mr / mc_ver-cf) found"); + bail!("no platform folders (mc_ver-mr / mc_ver-cf) found matching manifest"); } let mut handles = Vec::new(); @@ -211,6 +220,7 @@ struct OutputData<'a> { mc_ver: &'a str, p_type: &'a str, loader: &'a str, + release_type: &'a str, p_dir: &'a Path, } @@ -230,6 +240,7 @@ fn write_outputs(d: OutputData) -> Result<()> { writeln!(f, "mc={}", d.mc_ver)?; writeln!(f, "type={}", d.p_type)?; writeln!(f, "loader={}", d.loader)?; + writeln!(f, "release_type={}", d.release_type)?; writeln!(f, "path={}", d.p_dir.display())?; Ok(()) } \ No newline at end of file diff --git a/src/actions/somnus/core/src/bin/json-linter.rs b/src/actions/somnus/core/src/bin/json-linter.rs index 52f341fdc..b5f990bd9 100644 --- a/src/actions/somnus/core/src/bin/json-linter.rs +++ b/src/actions/somnus/core/src/bin/json-linter.rs @@ -1,4 +1,4 @@ -use somnus_core::lint_changed_files; +use somnus_core::linter::lint_changed_files; const WATCHED_PREFIXES: &[&str] = &["modpacks/", "datapacks/"]; const WATCHED_EXTS: &[&str] = &[".json", ".mcmeta"]; diff --git a/src/actions/somnus/core/src/bin/toml-linter.rs b/src/actions/somnus/core/src/bin/toml-linter.rs index be54403cc..035d25d9a 100644 --- a/src/actions/somnus/core/src/bin/toml-linter.rs +++ b/src/actions/somnus/core/src/bin/toml-linter.rs @@ -1,4 +1,4 @@ -use somnus_core::lint_changed_files; +use somnus_core::linter::lint_changed_files; const WATCHED_PREFIXES: &[&str] = &["modpacks/", "datapacks/"]; diff --git a/tools/manifest/schema.json b/tools/manifest/schema.json new file mode 100644 index 000000000..d90d14934 --- /dev/null +++ b/tools/manifest/schema.json @@ -0,0 +1,57 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Pack Manifest", + "description": "Defines a publishable pack in the monorepo", + "type": "object", + "required": ["id", "name", "type", "loader", "mc_version", "version", "release_type"], + "additionalProperties": false, + "properties": { + "$schema": { + "type": "string", + "description": "Path to this schema file." + }, + "id": { + "type": "string", + "description": "Matches the directory name", + "minLength": 1 + }, + "name": { + "type": "string", + "description": "Human-readable pack name shown on Modrinth/CurseForge. Ie. Re-Console Plus", + "minLength": 1 + }, + "type": { + "type": "string", + "description": "Pack type. 'modpack' exports via packwiz, 'datapack' zips the content directory", + "enum": ["modpack", "datapack"] + }, + "loader": { + "type": "string", + "description": "Mod loader this pack targets (e.g., 'fabric', 'neoforge', 'forge', 'quilt')", + "minLength": 1 + }, + "mc_version": { + "type": "string", + "description": "Target Minecraft version (e.g., '1.21.10')", + "minLength": 1 + }, + "version": { + "type": "string", + "description": "Pack version.", + "minLength": 1 + }, + "release_type": { + "type": "string", + "description": "Release channel", + "enum": ["release", "beta", "alpha"] + }, + "modrinth_id": { + "type": "string", + "description": "Modrinth project ID or slug (e.g., 'legacy-minecraft'). Leave empty if not publishing to Modrinth" + }, + "curseforge_id": { + "type": "string", + "description": "CurseForge project slug or numeric ID (e.g., 're-console'). Leave empty if not publishing to CurseForge" + } + } +} diff --git a/tools/manifest/validate.ts b/tools/manifest/validate.ts new file mode 100644 index 000000000..5f7f95e67 --- /dev/null +++ b/tools/manifest/validate.ts @@ -0,0 +1,112 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +interface Manifest { + id: string; + name: string; + type: 'modpack' | 'datapack'; + loader: string; + mc_version: string; + version: string; + release_type: 'release' | 'beta' | 'alpha'; + modrinth_id?: string; + curseforge_id?: string; +} + +function fail(msg: string): never { + console.error(`::error::${msg}`); + process.exit(1); +} + +function warn(msg: string): void { + console.warn(`::warning::${msg}`); +} + +function validate(manifestPath: string): void { + if (!fs.existsSync(manifestPath)) { + fail(`manifest not found: ${manifestPath}`); + } + + let manifest: Manifest; + try { + const raw = fs.readFileSync(manifestPath, 'utf-8'); + manifest = JSON.parse(raw); + } catch (e) { + fail(`failed to parse ${manifestPath}: ${e instanceof Error ? e.message : e}`); + } + + const required = [ + 'id', 'name', 'type', 'loader', 'mc_version', 'version', 'release_type', + ] as const; + for (const field of required) { + const v = manifest[field]; + if (v === undefined || v === null || v === '') { + fail(`manifest missing required field: ${field}`); + } + } + + if (!['modpack', 'datapack'].includes(manifest.type)) { + fail(`invalid 'type': ${manifest.type} (must be 'modpack' or 'datapack')`); + } + if (!['release', 'beta', 'alpha'].includes(manifest.release_type)) { + fail(`invalid 'release_type': ${manifest.release_type} (must be 'release', 'beta', or 'alpha')`); + } + + const hasMr = manifest.modrinth_id && manifest.modrinth_id.trim() !== ''; + const hasCf = manifest.curseforge_id && manifest.curseforge_id.trim() !== ''; + if (!hasMr && !hasCf) { + fail('manifest must set at least one of modrinth_id or curseforge_id'); + } + + const packDir = path.dirname(manifestPath); + + const changelogPath = path.join(packDir, 'changelog.md'); + if (!fs.existsSync(changelogPath)) { + fail(`changelog.md is missing at ${changelogPath}`); + } + const changelog = fs.readFileSync(changelogPath, 'utf-8').trim(); + if (changelog === '') { + fail(`changelog.md is empty at ${changelogPath}`); + } + const contentLines = changelog.split('\n').filter(l => l.trim() && !l.trim().startsWith('#')); + if (contentLines.length === 0) { + fail(`changelog.md has headers but no content at ${changelogPath}`); + } + + if (manifest.type === 'modpack') { + const mr = path.join(packDir, `${manifest.mc_version}-mr`); + const cf = path.join(packDir, `${manifest.mc_version}-cf`); + + if (hasMr && !fs.existsSync(mr)) { + fail(`modrinth_id is set but ${mr} does not exist`); + } + if (hasCf && !fs.existsSync(cf)) { + fail(`curseforge_id is set but ${cf} does not exist`); + } + if (fs.existsSync(mr) && !hasMr) { + warn(`${mr} exists but modrinth_id is not set`); + } + if (fs.existsSync(cf) && !hasCf) { + warn(`${cf} exists but curseforge_id is not set`); + } + } + + if (manifest.type === 'datapack') { + const content = path.join(packDir, 'content'); + if (!fs.existsSync(content)) { + fail(`datapack content directory missing: ${content}`); + } + } + + console.log(`${manifest.id} ${manifest.version} (${manifest.release_type}) — manifest OK`); +} + +const args = process.argv.slice(2); +if (args.length === 0) { + console.error('usage: tsx validate.ts [more manifests...]'); + process.exit(1); +} + +for (const manifestPath of args) { + validate(manifestPath); +}