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:
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 }}"
loaders: ${{ steps.meta.outputs.type == 'modpack' && steps.meta.outputs.loader || 'minecraft' }}
game-versions: "${{ steps.meta.outputs.mc }}"

View File

@@ -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<String> = env::args().collect();
let manifest_path_str = args.get(1).expect("Usage: publish <path_to_manifest.json>");
#[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<String> = env::args().collect();
let manifest_path_str = args
.get(1)
.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 {
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(())
}