chore(ci): improve auto publish

This commit is contained in:
omo50
2026-04-18 14:55:07 -06:00
parent b220dfed92
commit a5dbd2cc7c
2 changed files with 286 additions and 117 deletions

View File

@@ -2,21 +2,63 @@ name: "Publish"
on: on:
push: push:
branches: [ "main" ] branches: ["main"]
paths: paths:
- 'modpacks/**/manifest.json' - 'modpacks/**/manifest.json'
- 'datapacks/**/manifest.json' - 'datapacks/**/manifest.json'
- 'resourcepacks/**/manifest.json' workflow_dispatch:
jobs: jobs:
publish: detect:
runs-on: technocality runs-on: technocality
outputs:
manifests: ${{ steps.find.outputs.manifests }}
has_manifests: ${{ steps.find.outputs.has_manifests }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v5 uses: actions/checkout@v5
with: with:
fetch-depth: 0 fetch-depth: 2
filter: blob:none 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 - name: Set up Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -25,48 +67,61 @@ jobs:
- name: Generate Changelog - name: Generate Changelog
id: changelog id: changelog
run: | run: npx tsx tools/changelog/generate-changelog.ts "${{ matrix.manifest }}"
# 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"
- name: Setup Go - name: Cache Publisher Binary
uses: actions/setup-go@v5 id: cache-publisher
uses: actions/cache@v4
with: with:
go-version: 'stable' path: ./publisher-bin
cache: true 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 - name: Cache Packwiz Binaries
id: cache-go id: cache-go
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: ~/go/bin 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' if: steps.cache-go.outputs.cache-hit != 'true'
run: go install github.com/packwiz/packwiz@latest run: go install github.com/packwiz/packwiz@latest
- name: Add Path - name: Add Go bin to PATH
run: echo "$(go env GOPATH)/bin" >> $GITHUB_PATH run: echo "$HOME/go/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: Run Publisher - name: Run Publisher
id: meta id: meta
run: | 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 - 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 uses: https://github.com/Kir-Antipov/mc-publish@v3.3
with: with:
modrinth-id: ${{ steps.meta.outputs.mr_id }} modrinth-id: ${{ steps.meta.outputs.mr_id }}
@@ -78,5 +133,5 @@ jobs:
name: "${{ steps.meta.outputs.name }}" name: "${{ steps.meta.outputs.name }}"
version: "${{ steps.meta.outputs.ver }}" version: "${{ steps.meta.outputs.ver }}"
changelog: "${{ steps.changelog.outputs.notes }}" changelog: "${{ steps.changelog.outputs.notes }}"
loaders: ${{ steps.meta.outputs.type == 'modpack' && 'fabric' || 'minecraft' }} loaders: ${{ steps.meta.outputs.type == 'modpack' && steps.meta.outputs.loader || 'minecraft' }}
game-versions: "${{ steps.meta.outputs.mc }}" game-versions: "${{ steps.meta.outputs.mc }}"

View File

@@ -1,101 +1,215 @@
use anyhow::{anyhow, bail, Context, Result};
use serde_json::Value; use serde_json::Value;
use std::env; use std::{
use std::fs::{self, OpenOptions}; env,
use std::io::Write; fs::{self, OpenOptions},
use std::path::{Path, PathBuf}; io::Write,
use std::process::Command; path::Path,
process::Command,
};
fn main() { #[derive(Clone, Copy)]
let args: Vec<String> = env::args().collect(); enum Platform {
let manifest_path_str = args.get(1).expect("Usage: publish <path_to_manifest.json>"); Modrinth,
Curseforge,
}
let manifest_path = Path::new(manifest_path_str); impl Platform {
let p_dir = manifest_path.parent().expect("Could not find parent directory"); const ALL: [Platform; 2] = [Platform::Modrinth, Platform::Curseforge];
let manifest_content = fs::read_to_string(manifest_path).expect("Failed to read manifest"); fn short(self) -> &'static str {
let manifest: Value = serde_json::from_str(&manifest_content).expect("Failed to parse manifest JSON"); match self {
Platform::Modrinth => "mr",
let raw_name = manifest["name"].as_str().unwrap(); Platform::Curseforge => "cf",
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);
} }
} }
println!("::endgroup::");
if let Ok(out_path) = env::var("GITHUB_OUTPUT") { fn cli(self) -> &'static str {
let mut out_file = OpenOptions::new() match self {
.append(true) Platform::Modrinth => "modrinth",
.create(true) Platform::Curseforge => "curseforge",
.open(out_path) }
.expect("Could not open GITHUB_OUTPUT"); }
writeln!(out_file, "mr_id={}", mr_id).unwrap(); fn ext(self) -> &'static str {
writeln!(out_file, "cf_id={}", cf_id).unwrap(); match self {
writeln!(out_file, "name={} {}", raw_name, p_ver).unwrap(); Platform::Modrinth => "mrpack",
writeln!(out_file, "ver={}", p_ver).unwrap(); Platform::Curseforge => "zip",
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 run_cmd(cmd: &str, args: &[&str], dir: &Path) { fn main() -> Result<()> {
let status = Command::new(cmd) let args: Vec<String> = env::args().collect();
.args(args) let manifest_path_str = args
.current_dir(dir) .get(1)
.status(); .ok_or_else(|| anyhow!("usage: publish <path_to_manifest.json>"))?;
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 { let manifest_content = fs::read_to_string(manifest_path)
Ok(s) if s.success() => (), .with_context(|| format!("failed to read {}", manifest_path.display()))?;
Ok(s) => { let manifest: Value = serde_json::from_str(&manifest_content)
eprintln!("Command '{} {:?}' failed with exit code: {}", cmd, args, s); .with_context(|| format!("invalid JSON in {}", manifest_path.display()))?;
std::process::exit(1);
} let raw_name = required_str(&manifest, "name")?;
Err(e) => { let p_name = raw_name.replace(' ', "-");
eprintln!("Failed to execute '{}': {}", cmd, e); let p_ver = required_str(&manifest, "version")?;
std::process::exit(1); 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(())
} }