From a5dbd2cc7c2d6444d5f668fb4738405e37d4b94a Mon Sep 17 00:00:00 2001 From: omo50 <144749186+omo50@users.noreply.github.com> Date: Sat, 18 Apr 2026 14:55:07 -0600 Subject: [PATCH] chore(ci): improve auto publish --- .forgejo/workflows/publish.yml | 115 +++++++++---- src/actions/publish/publish.rs | 288 +++++++++++++++++++++++---------- 2 files changed, 286 insertions(+), 117 deletions(-) diff --git a/.forgejo/workflows/publish.yml b/.forgejo/workflows/publish.yml index 0a4123622..38c4f5b7c 100644 --- a/.forgejo/workflows/publish.yml +++ b/.forgejo/workflows/publish.yml @@ -2,21 +2,63 @@ name: "Publish" on: push: - branches: [ "main" ] + branches: ["main"] paths: - 'modpacks/**/manifest.json' - 'datapacks/**/manifest.json' - - 'resourcepacks/**/manifest.json' + workflow_dispatch: jobs: - publish: + detect: runs-on: technocality + outputs: + manifests: ${{ steps.find.outputs.manifests }} + has_manifests: ${{ steps.find.outputs.has_manifests }} steps: - name: Checkout uses: actions/checkout@v5 with: - fetch-depth: 0 + fetch-depth: 2 filter: blob:none + sparse-checkout: | + modpacks + datapacks + + - name: Find changed manifests + id: find + run: | + MANIFESTS=$(git diff-tree --no-commit-id --name-only -r HEAD \ + | grep -E '^(modpacks|datapacks)/.*/manifest\.json$' || true) + if [ -z "$MANIFESTS" ]; then + echo "has_manifests=false" >> $GITHUB_OUTPUT + echo "manifests=[]" >> $GITHUB_OUTPUT + echo "no changed manifests." + else + JSON=$(echo "$MANIFESTS" | jq -R -s -c 'split("\n") | map(select(length > 0))') + echo "has_manifests=true" >> $GITHUB_OUTPUT + echo "manifests=$JSON" >> $GITHUB_OUTPUT + echo "manifests to publish: $JSON" + fi + + publish: + needs: detect + if: needs.detect.outputs.has_manifests == 'true' + runs-on: technocality + strategy: + fail-fast: false + matrix: + manifest: ${{ fromJson(needs.detect.outputs.manifests) }} + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + fetch-depth: 0 + filter: blob:none + sparse-checkout: | + modpacks + datapacks + src/actions/publish + tools/changelog - name: Set up Node uses: actions/setup-node@v4 @@ -25,48 +67,61 @@ jobs: - name: Generate Changelog id: changelog - run: | - # The blobless fetch allows this git command to work perfectly - MANIFEST=$(git diff-tree --no-commit-id --name-only -r HEAD | grep 'manifest.json' | head -n 1) - echo "manifest=$MANIFEST" >> $GITHUB_OUTPUT - - npx tsx src/scripts/changelog.ts "$MANIFEST" + run: npx tsx tools/changelog/generate-changelog.ts "${{ matrix.manifest }}" - - name: Setup Go - uses: actions/setup-go@v5 + - name: Cache Publisher Binary + id: cache-publisher + uses: actions/cache@v4 with: - go-version: 'stable' - cache: true + path: ./publisher-bin + key: publisher-v1-${{ runner.os }}-${{ hashFiles('src/actions/publish/**/*.rs', 'src/actions/publish/Cargo.toml', 'src/actions/publish/Cargo.lock') }} + + - name: Install Rust + if: steps.cache-publisher.outputs.cache-hit != 'true' + uses: dtolnay/rust-toolchain@stable + + - name: Rust Cache + if: steps.cache-publisher.outputs.cache-hit != 'true' + uses: Swatinem/rust-cache@v2 + with: + workspaces: "src/actions/publish -> target" + + - name: Build Publisher + if: steps.cache-publisher.outputs.cache-hit != 'true' + run: | + cargo build --release --manifest-path src/actions/publish/Cargo.toml --bin publish + mkdir -p ./publisher-bin + cp src/actions/publish/target/release/publish ./publisher-bin/publish - name: Cache Packwiz Binaries id: cache-go uses: actions/cache@v4 with: path: ~/go/bin - key: go-bin-v1-${{ runner.os }} + key: go-bin-packwiz-v1-${{ runner.os }} - - name: Install packwiz + - name: Setup Go + if: steps.cache-go.outputs.cache-hit != 'true' + uses: actions/setup-go@v5 + with: + go-version: 'stable' + cache: true + + - name: Install Packwiz if: steps.cache-go.outputs.cache-hit != 'true' run: go install github.com/packwiz/packwiz@latest - - name: Add Path - run: echo "$(go env GOPATH)/bin" >> $GITHUB_PATH - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - - - name: Rust Cache - uses: Swatinem/rust-cache@v2 - with: - workspaces: "src/actions -> target" + - name: Add Go bin to PATH + run: echo "$HOME/go/bin" >> $GITHUB_PATH - name: Run Publisher id: meta run: | - cargo run --release --manifest-path src/actions/Cargo.toml --bin publish -- "${{ steps.changelog.outputs.manifest }}" + chmod +x ./publisher-bin/publish + ./publisher-bin/publish "${{ matrix.manifest }}" - name: Upload to Platforms - if: "steps.meta.outputs.mr_id != ''" + if: "steps.meta.outputs.mr_id != '' || steps.meta.outputs.cf_id != ''" uses: https://github.com/Kir-Antipov/mc-publish@v3.3 with: modrinth-id: ${{ steps.meta.outputs.mr_id }} @@ -78,5 +133,5 @@ jobs: name: "${{ steps.meta.outputs.name }}" version: "${{ steps.meta.outputs.ver }}" changelog: "${{ steps.changelog.outputs.notes }}" - loaders: ${{ steps.meta.outputs.type == 'modpack' && 'fabric' || 'minecraft' }} - game-versions: "${{ steps.meta.outputs.mc }}" \ No newline at end of file + loaders: ${{ steps.meta.outputs.type == 'modpack' && steps.meta.outputs.loader || 'minecraft' }} + game-versions: "${{ steps.meta.outputs.mc }}" diff --git a/src/actions/publish/publish.rs b/src/actions/publish/publish.rs index bc553bc10..76bff08c5 100644 --- a/src/actions/publish/publish.rs +++ b/src/actions/publish/publish.rs @@ -1,101 +1,215 @@ +use anyhow::{anyhow, bail, Context, Result}; use serde_json::Value; -use std::env; -use std::fs::{self, OpenOptions}; -use std::io::Write; -use std::path::{Path, PathBuf}; -use std::process::Command; +use std::{ + env, + fs::{self, OpenOptions}, + io::Write, + path::Path, + process::Command, +}; -fn main() { - let args: Vec = env::args().collect(); - let manifest_path_str = args.get(1).expect("Usage: publish "); +#[derive(Clone, Copy)] +enum Platform { + Modrinth, + Curseforge, +} - let manifest_path = Path::new(manifest_path_str); - let p_dir = manifest_path.parent().expect("Could not find parent directory"); +impl Platform { + const ALL: [Platform; 2] = [Platform::Modrinth, Platform::Curseforge]; - let manifest_content = fs::read_to_string(manifest_path).expect("Failed to read manifest"); - let manifest: Value = serde_json::from_str(&manifest_content).expect("Failed to parse manifest JSON"); - - let raw_name = manifest["name"].as_str().unwrap(); - let p_name = raw_name.replace(" ", "-"); - let p_ver = manifest["version"].as_str().unwrap(); - let mc_ver = manifest["mc_version"].as_str().unwrap(); - let p_type = manifest["type"].as_str().unwrap(); - let mr_id = manifest["modrinth_id"].as_str().unwrap_or(""); - let cf_id = manifest["curseforge_id"].as_str().unwrap_or(""); - - let filename_base = format!("{}-{}-fabric-{}", p_name, mc_ver, p_ver); - - let workspace = env::var("GITHUB_WORKSPACE").unwrap_or_else(|_| ".".to_string()); - let artifacts_dir = Path::new(&workspace).join(p_dir).join("artifacts"); - - if artifacts_dir.exists() { - fs::remove_dir_all(&artifacts_dir).unwrap(); - } - fs::create_dir_all(&artifacts_dir).unwrap(); - - println!("::group::Building Artifacts for {}", raw_name); - - if p_type == "modpack" { - for platform in &["mr", "cf"] { - let target_folder = format!("{}-{}", mc_ver, platform); - let target_path = p_dir.join(&target_folder); - - if target_path.exists() { - run_cmd("packwiz", &["refresh"], &target_path); - - let (export_cmd, ext) = if *platform == "mr" { ("modrinth", "mrpack") } else { ("curseforge", "zip") }; - let out_file = artifacts_dir.join(format!("{}-{}.{}", filename_base, platform, ext)); - - run_cmd("packwiz", &[export_cmd, "export", "--output", out_file.to_str().unwrap()], &target_path); - } else { - println!("Skipping {}: folder {} not found", platform, target_path.display()); - } - } - } else if p_type == "resourcepack" || p_type == "datapack" { - let out_file = artifacts_dir.join(format!("{}-{}.zip", manifest["id"].as_str().unwrap_or("project"), p_ver)); - let content_dir = p_dir.join("content"); - - if content_dir.exists() { - run_cmd("zip", &["-r", out_file.to_str().unwrap(), "."], &content_dir); - } else { - println!("Error: content directory not found at {}", content_dir.display()); - std::process::exit(1); + fn short(self) -> &'static str { + match self { + Platform::Modrinth => "mr", + Platform::Curseforge => "cf", } } - println!("::endgroup::"); - if let Ok(out_path) = env::var("GITHUB_OUTPUT") { - let mut out_file = OpenOptions::new() - .append(true) - .create(true) - .open(out_path) - .expect("Could not open GITHUB_OUTPUT"); + fn cli(self) -> &'static str { + match self { + Platform::Modrinth => "modrinth", + Platform::Curseforge => "curseforge", + } + } - writeln!(out_file, "mr_id={}", mr_id).unwrap(); - writeln!(out_file, "cf_id={}", cf_id).unwrap(); - writeln!(out_file, "name={} {}", raw_name, p_ver).unwrap(); - writeln!(out_file, "ver={}", p_ver).unwrap(); - writeln!(out_file, "mc={}", mc_ver).unwrap(); - writeln!(out_file, "type={}", p_type).unwrap(); - writeln!(out_file, "path={}", p_dir.to_str().unwrap()).unwrap(); + fn ext(self) -> &'static str { + match self { + Platform::Modrinth => "mrpack", + Platform::Curseforge => "zip", + } } } -fn run_cmd(cmd: &str, args: &[&str], dir: &Path) { - let status = Command::new(cmd) - .args(args) - .current_dir(dir) - .status(); +fn main() -> Result<()> { + let args: Vec = env::args().collect(); + let manifest_path_str = args + .get(1) + .ok_or_else(|| anyhow!("usage: publish "))?; + let manifest_path = Path::new(manifest_path_str); + let p_dir = manifest_path + .parent() + .ok_or_else(|| anyhow!("manifest has no parent directory"))?; - match status { - Ok(s) if s.success() => (), - Ok(s) => { - eprintln!("Command '{} {:?}' failed with exit code: {}", cmd, args, s); - std::process::exit(1); - } - Err(e) => { - eprintln!("Failed to execute '{}': {}", cmd, e); - std::process::exit(1); - } + let manifest_content = fs::read_to_string(manifest_path) + .with_context(|| format!("failed to read {}", manifest_path.display()))?; + let manifest: Value = serde_json::from_str(&manifest_content) + .with_context(|| format!("invalid JSON in {}", manifest_path.display()))?; + + let raw_name = required_str(&manifest, "name")?; + let p_name = raw_name.replace(' ', "-"); + 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 mr_id = manifest["modrinth_id"].as_str().unwrap_or(""); + let cf_id = manifest["curseforge_id"].as_str().unwrap_or(""); + + 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() { + fs::remove_dir_all(&artifacts_dir) + .with_context(|| format!("failed to clear {}", artifacts_dir.display()))?; } + fs::create_dir_all(&artifacts_dir) + .with_context(|| format!("failed to create {}", artifacts_dir.display()))?; + + println!("::group::Building artifacts for {raw_name}"); + + match p_type { + "modpack" => build_modpack(p_dir, &artifacts_dir, &p_name, mc_ver, p_ver, loader)?, + "datapack" => build_datapack(p_dir, &artifacts_dir, &manifest, p_ver)?, + other => bail!("unsupported pack type: {other}"), + } + + println!("::endgroup::"); + + write_outputs(OutputData { + mr_id, + cf_id, + raw_name, + p_ver, + mc_ver, + p_type, + loader, + p_dir, + })?; + + Ok(()) +} + +fn required_str<'a>(value: &'a Value, key: &str) -> Result<&'a str> { + value[key] + .as_str() + .ok_or_else(|| anyhow!("missing or non-string '{key}' in manifest")) +} + +fn build_modpack( + p_dir: &Path, + artifacts_dir: &Path, + p_name: &str, + mc_ver: &str, + p_ver: &str, + loader: &str, +) -> Result<()> { + let filename_base = format!("{p_name}-{mc_ver}-{loader}-{p_ver}"); + let mut built = 0; + + for platform in Platform::ALL { + let target_folder = format!("{mc_ver}-{}", platform.short()); + let target_path = p_dir.join(&target_folder); + if !target_path.exists() { + println!( + "skipping {}: folder {} not found", + platform.short(), + target_path.display() + ); + continue; + } + + let refresh = Command::new("packwiz") + .args(["refresh", "-y"]) + .current_dir(&target_path) + .status() + .context("failed to invoke packwiz refresh")?; + if !refresh.success() { + bail!("packwiz refresh failed in {}", target_path.display()); + } + + let out_file = artifacts_dir.join(format!( + "{filename_base}-{}.{}", + platform.short(), + platform.ext() + )); + let out_str = out_file + .to_str() + .ok_or_else(|| anyhow!("non-UTF8 output path"))?; + + let export = Command::new("packwiz") + .args([platform.cli(), "export", "--output", out_str]) + .current_dir(&target_path) + .status() + .context("failed to invoke packwiz export")?; + if !export.success() { + bail!("packwiz export failed for {target_folder}"); + } + built += 1; + } + + if built == 0 { + bail!("no platform folders (mc_ver-mr / mc_ver-cf) found"); + } + Ok(()) +} + +fn build_datapack(p_dir: &Path, artifacts_dir: &Path, manifest: &Value, p_ver: &str) -> Result<()> { + let id = manifest["id"] + .as_str() + .ok_or_else(|| anyhow!("datapack manifest missing 'id' field"))?; + let out_file = artifacts_dir.join(format!("{id}-{p_ver}.zip")); + let content_dir = p_dir.join("content"); + if !content_dir.exists() { + bail!("content directory not found at {}", content_dir.display()); + } + let out_str = out_file + .to_str() + .ok_or_else(|| anyhow!("non-UTF8 output path"))?; + let status = Command::new("zip") + .args(["-r", out_str, "."]) + .current_dir(&content_dir) + .status() + .context("failed to invoke zip")?; + if !status.success() { + bail!("zip failed for datapack"); + } + Ok(()) +} + +struct OutputData<'a> { + mr_id: &'a str, + cf_id: &'a str, + raw_name: &'a str, + p_ver: &'a str, + mc_ver: &'a str, + p_type: &'a str, + loader: &'a str, + p_dir: &'a Path, +} + +fn write_outputs(d: OutputData) -> Result<()> { + let Ok(out_path) = env::var("GITHUB_OUTPUT") else { + return Ok(()); + }; + let mut f = OpenOptions::new() + .append(true) + .create(true) + .open(&out_path) + .with_context(|| format!("failed to open {out_path}"))?; + writeln!(f, "mr_id={}", d.mr_id)?; + writeln!(f, "cf_id={}", d.cf_id)?; + writeln!(f, "name={} {}", d.raw_name, d.p_ver)?; + writeln!(f, "ver={}", d.p_ver)?; + writeln!(f, "mc={}", d.mc_ver)?; + writeln!(f, "type={}", d.p_type)?; + writeln!(f, "loader={}", d.loader)?; + writeln!(f, "path={}", d.p_dir.display())?; + Ok(()) } \ No newline at end of file