minecraft-ci-release
Set up and review CI, artifact publishing, versioning, and release governance for Minecraft 26.x or legacy 1.21.x mods and Paper plugins. Use for pipelines and releases, not gameplay implementation or server operations.
Install
npx skills add https://github.com/Jahrome907/minecraft-agent-skills/tree/main/.agents/skills/minecraft-ci-release
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install jahrome907-minecraft-agent-skills@llmmart
git clone https://github.com/Jahrome907/minecraft-agent-skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole jahrome907/minecraft-agent-skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Minecraft CI / Release Skill
Routing Boundaries
Use when: the task is CI/CD pipelines, artifact publishing, versioning, or release management.Do not use when: the task is gameplay or plugin implementation (minecraft-modding,minecraft-plugin-dev, orminecraft-datapack).Do not use when: the task is server runtime operations or infrastructure tuning (minecraft-server-admin).
Release setup
Keep each repository's existing release destinations and approval policy. A tag can build an artifact and create a GitHub Release without publishing to Modrinth or CurseForge. Add a publisher only when the project already uses it or the user asks for it. Never add tokens to committed files.
For Minecraft 26.x, use Java 25 and state that in workflow labels and examples. Legacy Minecraft 1.21.x projects stay on Java 21; retain their own loader and Gradle conventions instead of mechanically changing their version.
Use an immutable GitHub Action revision in a protected workflow. The following refs were verified from the upstream tags on 2026-09-04; refresh them from the upstream tag before intentionally upgrading an action:
actions/checkout@v7.0.1 3d3c42e5aac5ba805825da76410c181273ba90b1
actions/setup-java@v6.0.0 dd06d9cba3e5552c54d9f8ea23572deb30010f7c
gradle/actions/setup-gradle@v6.3.0 9c971963bec38e04b3d30dcc455b5382be2fdbfb
actions/upload-artifact@v7.0.1 043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
softprops/action-gh-release@v3.0.3 efb35369e0ad2afab669f228072c1b0d510eae64
Version and Tag Convention
Minecraft mod versions follow: {mod_version}+{mc_version}
1.0.0+26.2 ← mod 1.0.0 for MC 26.2
1.2.3+26.2
2.0.0+26.2
Use a release version without the game suffix for the Git tag, and retain the Minecraft version in the artifact version when the project uses that convention:
mod_version: 1.2.3
project/artifact version: 1.2.3+26.2
tag: v1.2.3
The release workflow must verify that v1.2.3 matches mod_version=1.2.3 before
building. Do not override mod_version from the tag, because that hides a mismatch.
The Gradle task and safe changelog extraction are in
the publishing reference.
Core CI Workflow (NeoForge + Fabric)
This is a Minecraft 26.x / Java 25 example. Its displayed check names are
Build / Build (fabric), Build / Build (neoforge), and Build / Test after
the test job below is added. After the first successful pull request, copy the
exact names GitHub displays into branch protection; workflow or job renames
change the required-check context.
.github/workflows/build.yml
name: Build
on:
push:
branches: ["main", "develop"]
pull_request:
branches: ["main"]
permissions:
contents: read
jobs:
build:
name: Build (${{ matrix.platform }})
runs-on: ubuntu-latest
strategy:
matrix:
platform: [neoforge, fabric]
fail-fast: false
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- name: Set up Java 25
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c
with:
java-version: "25"
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb
with:
cache-read-only: ${{ github.event_name == 'pull_request' }}
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Build (${{ matrix.platform }})
run: ./gradlew :${{ matrix.platform }}:build --no-daemon
- name: Upload artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: mod-${{ matrix.platform }}-${{ github.sha }}
path: ${{ matrix.platform }}/build/libs/*.jar
if-no-files-found: error
test:
name: Test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- name: Set up Java 25
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c
with:
distribution: temurin
java-version: "25"
- name: Set up Gradle
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb
- name: Run tests
run: ./gradlew test --no-daemon
Tagged GitHub Release
.github/workflows/release.yml
name: Release
on:
push:
tags:
- "v*"
permissions:
contents: write # for creating GitHub releases
jobs:
release:
name: Release
runs-on: ubuntu-latest
steps:
- name: Checkout tagged source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- name: Set up Java 25
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c
with:
java-version: "25"
distribution: "temurin"
- name: Setup Gradle
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Validate tag and build
env:
MOD_VERSION: ${{ github.ref_name }}
run: |
MOD_VERSION="${MOD_VERSION#v}"
./gradlew verifyReleaseVersion build --no-daemon \
-PreleaseModVersion="$MOD_VERSION"
- name: Select release artifacts
env:
RELEASE_TAG: ${{ github.ref_name }}
run: |
mkdir -p release-artifacts
shopt -s nullglob
select_primary() {
local loader="$1"; shift
local matches=( "$@" )
if (( ${#matches[@]} != 1 )); then
printf 'Expected one %s primary JAR, found %s: %s\n' \
"$loader" "${#matches[@]}" "${matches[*]:-none}" >&2
exit 1
fi
cp "${matches[0]}" "release-artifacts/${loader}-${RELEASE_TAG}.jar"
}
select_primary fabric fabric/build/libs/*-fabric.jar
select_primary neoforge neoforge/build/libs/*-neoforge.jar
- name: Create GitHub Release
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64
with:
files: |
release-artifacts/fabric-${{ github.ref_name }}.jar
release-artifacts/neoforge-${{ github.ref_name }}.jar
fail_on_unmatched_files: true
generate_release_notes: true
prerelease: ${{ contains(github.ref_name, '-alpha') || contains(github.ref_name, '-beta') || contains(github.ref_name, '-rc') }}
This workflow creates a GitHub Release only. It expects one primary Fabric JAR and one primary NeoForge JAR with loader-distinct names. Configure those classifiers in the project build, then change both patterns together if its naming convention differs. The selection step fails for zero or multiple matches and copies to distinct release names, preventing accidental overwrite or a partial release. Add a project-specific publisher after version validation only when that destination is in scope.
Paper Plugin CI
.github/workflows/build.yml (plugin)
name: Build
on:
push:
branches: ["main"]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c
with:
java-version: "25"
distribution: "temurin"
- uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb
- run: chmod +x gradlew
- run: ./gradlew build --no-daemon
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: plugin-${{ github.sha }}
path: build/libs/*.jar
if-no-files-found: error
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c
with:
java-version: "25"
distribution: "temurin"
- uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb
- run: ./gradlew test --no-daemon
Publishing and Changelog Details
Read the publishing reference only when the
project publishes to Modrinth or CurseForge. It includes current plugin versions,
26.x jar selection, explicit legacy Loom remapJar guidance, version verification,
and a parser that fails when the expected changelog heading is missing.
gradle.properties Secrets Pattern
Never hardcode tokens. Read them from environment:
# gradle.properties (committed)
mod_id=mymod
mod_version=1.0.0
minecraft_version=26.2
modrinth_project_id=AABBCCDD
curseforge_project_id=123456
# DO NOT commit tokens
# Set these as GitHub repo secrets:
# MODRINTH_TOKEN, CURSEFORGE_TOKEN
Semantic Versioning for Mods
| Change | Version bump |
|---|---|
| New features, no breaking changes | Minor: 1.1.0 |
| Bug fixes only | Patch: 1.0.1 |
| API/config breaking changes | Major: 2.0.0 |
| Minecraft version update | Keep mod version, change the +26.2 suffix |
| Pre-release | 1.0.0-beta.1, 1.0.0-rc.1 |
CHANGELOG.md Convention
# Changelog
## [1.1.0] — 2025-06-01
### Added
- New `/kit` command
- PDC-based kill tracker
### Fixed
- Death message not appearing on Paper 26.2
## [1.0.0] — 2025-05-01
### Added
- Initial release
The publishing reference extracts this section by mod_version and fails clearly
when the expected heading is missing.
Dependabot Configuration
.github/dependabot.yml
version: 2
updates:
- package-ecosystem: "gradle"
directory: "/"
schedule:
interval: "weekly"
groups:
gradle-plugins:
patterns:
- "com.gradleup.shadow"
- "dev.architectury.loom"
- "com.modrinth.minotaur"
- "net.darkhax.curseforgegradle"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
Build Caching Best Practices
# In all workflow jobs:
- name: Setup Gradle
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb
with:
# Read-only cache on PRs, read-write on main
cache-read-only: ${{ github.event_name == 'pull_request' }}
Branch Protection + Required Checks
Recommended GitHub branch protection for main:
- Require the observed checks:
Build / Build (fabric),Build / Build (neoforge), andBuild / Test - Require linear history (squash/rebase merges)
- Require signed commits (optional but recommended for release workflows)
Tag and Release Script
#!/usr/bin/env bash
# scripts/release.sh <mod-version>
set -euo pipefail
VERSION="${1:?Usage: release.sh <mod-version>}"
REMOTE="${RELEASE_REMOTE:-origin}"
BRANCH="${RELEASE_BRANCH:-main}"
EXPECTED_REMOTE_URL="${RELEASE_REMOTE_URL:?Set RELEASE_REMOTE_URL to the expected push URL}"
git diff --check
[[ -z "$(git status --porcelain=v1 --untracked-files=all)" ]] \
|| { echo "Working tree contains staged, unstaged, or untracked files." >&2; exit 1; }
[[ "$(git branch --show-current)" == "$BRANCH" ]] \
|| { echo "Release must start from branch $BRANCH." >&2; exit 1; }
mapfile -t PUSH_URLS < <(git remote get-url --push --all "$REMOTE")
[[ ${#PUSH_URLS[@]} -eq 1 && "${PUSH_URLS[0]}" == "$EXPECTED_REMOTE_URL" ]] \
|| { echo "Remote $REMOTE must have one expected push URL." >&2; exit 1; }
! git rev-parse --verify --quiet "refs/tags/v${VERSION}" >/dev/null \
|| { echo "Local tag v${VERSION} already exists." >&2; exit 1; }
set +e
git ls-remote --exit-code --tags "$EXPECTED_REMOTE_URL" "refs/tags/v${VERSION}" >/dev/null
REMOTE_TAG_STATUS=$?
set -e
case "$REMOTE_TAG_STATUS" in
0) echo "Remote tag v${VERSION} already exists." >&2; exit 1 ;;
2) ;;
*) echo "Could not verify remote tag v${VERSION}." >&2; exit "$REMOTE_TAG_STATUS" ;;
esac
./gradlew verifyReleaseVersion --no-daemon -PreleaseModVersion="$VERSION"
git tag --annotate "v${VERSION}" --message "Release v${VERSION}"
git push "$REMOTE" "HEAD:refs/heads/${BRANCH}"
git push "$REMOTE" "refs/tags/v${VERSION}"
Update and verify version/changelog files before this script, then tag that release
commit. Set RELEASE_BRANCH, RELEASE_REMOTE, and RELEASE_REMOTE_URL for the
intended release branch and exact push URL. Do not force-push or retag a published
release without explicit authorization.
Workflow Snippet Validator
Run the bundled validator from an installed minecraft-ci-release skill directory:
# Run from the installed skill directory:
./scripts/validate-workflow-snippets.sh --root .
# Strict mode treats warnings as failures:
./scripts/validate-workflow-snippets.sh --root . --strict
The validator is bundled and self-contained. Run it from a copied .agents/,
.codex/, or .claude/ minecraft-ci-release skill directory without relying
on repo-root node_modules.
It validates workflow-shaped YAML, unresolved placeholders, workflow secret
documentation, and remote uses: references pinned to full commit SHAs. Local
actions (./...) and Docker actions (docker://...) are intentionally excluded from
the SHA requirement. It reads only this skill's SKILL.md: it does not validate a
project's .github/workflows files or Gradle tasks. Before a real release, inspect
the project's generated artifacts, run verifyReleaseVersion, and review the exact
workflow diff and configured release destination.
References
Files (minecraft-agent-skills)
-
references
-
publishing-gradle.md 5.2 KB
# Gradle Publishing Reference Use these snippets only for a project that has chosen the relevant publisher. Keep its existing project IDs, artifact tasks, loader metadata, and release approval flow. The examples target Minecraft 26.x on Java 25. ## Release Version and Changelog Keep the mod version distinct from the computed project/artifact version. This makes `v1.2.3` a reliable tag for an artifact such as `1.2.3+26.2`. ```kotlin val modVersion = providers.gradleProperty("mod_version").orNull ?: throw GradleException("mod_version is required") val minecraftVersion = providers.gradleProperty("minecraft_version").orNull ?: throw GradleException("minecraft_version is required") version = "${modVersion}+${minecraftVersion}" fun changelogFor(releaseVersion: String): String { val changelog = rootProject.file("CHANGELOG.md").readText() val heading = "## [$releaseVersion]" val start = changelog.indexOf(heading) check(start >= 0) { "CHANGELOG.md is missing heading: $heading" } return changelog.substring(start + heading.length) .substringBefore("\n## [") .trim() .also { check(it.isNotBlank()) { "CHANGELOG.md section is empty: $heading" } } } tasks.register("verifyReleaseVersion") { group = "verification" doLast { val tagVersion = providers.gradleProperty("releaseModVersion").orNull ?: error("Pass -PreleaseModVersion=<version from v<version> tag>") check(tagVersion == modVersion) { "Tag version $tagVersion does not match mod_version=$modVersion" } changelogFor(tagVersion) } } ``` Use a changelog heading such as `## [1.2.3] — 2026-09-04`. Do not look up a heading from `project.version`, because it includes `+26.2`; `substringAfter` also must not be used without an explicit missing-heading check because it can return the entire file. ## Modrinth with Minotaur The current Gradle Plugin Portal release is `com.modrinth.minotaur` `2.9.0`. For a Minecraft 26.x Fabric project, upload the primary `jar` task. Current Minecraft is unobfuscated, so a new 26.x Fabric build should not assume `remapJar` exists. Give release artifacts loader-distinct classifiers when Fabric and NeoForge could otherwise produce the same basename. ```kotlin import org.gradle.api.tasks.bundling.Jar plugins { id("com.modrinth.minotaur") version "2.9.0" } modrinth { token.set(providers.environmentVariable("MODRINTH_TOKEN")) projectId.set(providers.gradleProperty("modrinth_project_id")) versionNumber.set(version.toString()) versionType.set("release") uploadFile.set(tasks.named<Jar>("jar")) gameVersions.add(minecraftVersion) loaders.add("fabric") changelog.set(changelogFor(modVersion)) } tasks.named<Jar>("jar") { archiveClassifier.set("fabric") } tasks.named("modrinth") { dependsOn(tasks.named("verifyReleaseVersion")) } ``` For a retained legacy Fabric Loom project that produces the distributable `remapJar`, select that task explicitly instead: ```kotlin uploadFile.set(tasks.named("remapJar")) ``` Confirm the task name and the produced file in that legacy project before changing the selection. NeoForge and multi-loader builds can have different platform tasks; configure each output independently. For a named NeoForge output, use its actual archive task and set `archiveClassifier` to `neoforge`; do not assume either loader uses the other one's task. ## CurseForge with CurseForgeGradle The current Gradle Plugin Portal release is `net.darkhax.curseforgegradle` `1.3.33`. Only add this task for a project that publishes to CurseForge. ```kotlin import org.gradle.api.tasks.bundling.Jar plugins { id("net.darkhax.curseforgegradle") version "1.3.33" } tasks.register<net.darkhax.curseforgegradle.TaskPublishCurseForge>("curseforge") { apiToken = providers.environmentVariable("CURSEFORGE_TOKEN").orNull ?: "" val mainFile = upload( providers.gradleProperty("curseforge_project_id").get(), tasks.named<Jar>("jar") ) mainFile.changelogType = "markdown" mainFile.changelog = changelogFor(modVersion) mainFile.releaseType = "release" mainFile.addGameVersion(minecraftVersion) mainFile.addModLoader("Fabric") mainFile.addJavaVersion("Java 25") mainFile.addEnvironment("Client", "Server") } tasks.named<Jar>("jar") { archiveClassifier.set("fabric") } tasks.named("curseforge") { dependsOn(tasks.named("verifyReleaseVersion")) } ``` Set the actual loader and supported environments for the artifact. CurseForgeGradle can infer loader, game, and Java metadata when the relevant project configuration is present; explicit metadata is useful only when it matches the released JAR. ## Optional Combined Task Only create a combined task when both publishers and all named platform tasks are already configured in the project: ```kotlin tasks.register("publishSelectedDestinations") { group = "publishing" dependsOn("modrinth", "curseforge") } ``` Do not add missing publisher plugins, task dependencies, project IDs, or secrets just to make this aggregation example apply. Invoke any publisher task with `-PreleaseModVersion=<tag version>`: each configured publish task depends on `verifyReleaseVersion`, so a missing tag version or mismatched changelog blocks the upload before it reaches a publisher.
-
-
scripts
-
vendor
-
js-yaml.min.cjs 60.2 KB · in bundle
-
LICENSE.js-yaml 1.1 KB · in bundle
-
-
validate-workflow-snippets.sh 6.6 KB
#!/usr/bin/env bash set -euo pipefail ROOT='.' STRICT=0 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" YAML_MODULE="$SCRIPT_DIR/vendor/js-yaml.min.cjs" while [[ $# -gt 0 ]]; do case "$1" in --root) ROOT="${2:-}" shift 2 ;; --strict) STRICT=1 shift ;; --help|-h) cat <<'USAGE' Usage: validate-workflow-snippets.sh [--root <path>] [--strict] Validates workflow snippets inside SKILL.md: - extracts fenced yaml/yml code blocks - checks required workflow keys (name/on/jobs) for workflow-like snippets - detects unresolved placeholders and obviously broken globs - checks secret usage vs documented secret list in SKILL.md - requires remote workflow actions to use a full commit SHA USAGE exit 0 ;; *) echo "[FAIL] unknown arg: $1" >&2 exit 1 ;; esac done SKILL_FILE="$ROOT/SKILL.md" if [[ ! -f "$SKILL_FILE" ]]; then echo "[FAIL] missing SKILL.md at $SKILL_FILE" exit 1 fi node - "$SKILL_FILE" "$STRICT" "$YAML_MODULE" <<'NODE' const fs = require('node:fs'); const skillFile = process.argv[2]; const strict = process.argv[3] === '1'; const yamlModule = process.argv[4]; let yaml; try { yaml = require(yamlModule); } catch (error) { console.error(`[FAIL] missing bundled YAML parser at ${yamlModule}: ${String(error.message || error)}`); process.exit(1); } const text = fs.readFileSync(skillFile, 'utf8').replace(/^\uFEFF/, '').replace(/\r\n/g, '\n'); let failures = 0; let warnings = 0; const pass = (msg) => console.log(`[PASS] ${msg}`); const warn = (msg) => { warnings += 1; console.log(`[WARN] ${msg}`); }; const fail = (msg) => { failures += 1; console.log(`[FAIL] ${msg}`); }; console.log('=== Workflow Snippet Validator ==='); const blocks = []; const fenceRe = /```(?:yaml|yml)\n([\s\S]*?)```/g; let match; while ((match = fenceRe.exec(text)) !== null) { blocks.push(match[1]); } if (blocks.length === 0) { fail('no yaml/yml fenced code blocks found in SKILL.md'); } const usedSecrets = new Set(); const documentedSecrets = new Set(); const secretUseRe = /\$\{\{\s*secrets\.([A-Z0-9_]+)\s*\}\}/g; const lines = text.split(/\r?\n/); let inSecretSection = false; for (const line of lines) { if (/^##+\s+.*secret/i.test(line)) { inSecretSection = true; continue; } if (inSecretSection && /^##+\s+/.test(line)) { inSecretSection = false; } if (!inSecretSection) continue; for (const m of line.matchAll(/`([A-Z][A-Z0-9_]{2,})`/g)) { documentedSecrets.add(m[1]); } for (const m of line.matchAll(/\b([A-Z][A-Z0-9_]*(?:TOKEN|SECRET|KEY))\b/g)) { documentedSecrets.add(m[1]); } } const placeholderRe = /(REPLACE_ME|TODO|<[^>]+>|yourname|your-repo|path\/to\/|example\/repo)/i; const badGlobRe = /\*\*\*|\*\*\/\*\*(?=\/|\s|$)|\.\*\*/; const mappingLineRe = /^(?:"[^"]+"|'[^']+'|[^:#][^:]*?):(?:\s+.*)?$/; const usesLineRe = /^\s*(?:-\s*)?uses:\s*['"]?([^'"#\s]+)['"]?(?:\s+#.*)?$/; function inspectBlock(block) { const topLevelKeys = new Set(); const lines = block.split(/\r?\n/); const significantIndents = []; for (const line of lines) { if (!line.trim()) continue; const indent = line.match(/^ */)[0].length; const trimmed = line.slice(indent); if (!trimmed || trimmed.startsWith('#')) continue; significantIndents.push(indent); } const baseIndent = significantIndents.length > 0 ? Math.min(...significantIndents) : 0; for (let idx = 0; idx < lines.length; idx += 1) { const line = lines[idx]; if (!line.trim()) continue; const indent = line.match(/^ */)[0].length; const trimmed = line.slice(indent); if (!trimmed || trimmed.startsWith('#')) continue; if (indent === baseIndent && mappingLineRe.test(trimmed)) { topLevelKeys.add(trimmed.split(':', 1)[0].trim().replace(/^['"]|['"]$/g, '')); } } return { topLevelKeys }; } blocks.forEach((block, idx) => { const label = `block #${idx + 1}`; const { topLevelKeys } = inspectBlock(block); const isWorkflowLike = topLevelKeys.has('jobs') || topLevelKeys.has('on'); const blockLines = block.split(/\r?\n/); if (isWorkflowLike) { let parsed; try { parsed = yaml.load(block); } catch (error) { const message = String(error.message || error).split('\n')[0]; fail(`${label} is not valid YAML: ${message}`); return; } if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { fail(`${label} is not valid YAML: top-level workflow document must be a mapping`); return; } const parsedTopLevelKeys = new Set(Object.keys(parsed)); if (!parsedTopLevelKeys.has('name')) fail(`${label} missing top-level \`name:\``); if (!parsedTopLevelKeys.has('on')) fail(`${label} missing top-level \`on:\``); if (!parsedTopLevelKeys.has('jobs')) fail(`${label} missing top-level \`jobs:\``); for (const secretMatch of block.matchAll(secretUseRe)) { usedSecrets.add(secretMatch[1]); } blockLines.forEach((line, lineIdx) => { const action = line.match(usesLineRe)?.[1]; if (!action || action.startsWith('./') || action.startsWith('docker://')) return; if (!action.includes('@')) { fail(`${label} line ${lineIdx + 1} action reference is missing a ref: ${action}`); } else if (!/@[0-9a-f]{40}$/.test(action)) { fail(`${label} line ${lineIdx + 1} action must be pinned to a full commit SHA: ${action}`); } }); } if (placeholderRe.test(block)) { fail(`${label} contains unresolved placeholder text`); } else { pass(`${label} has no obvious placeholder tokens`); } if (badGlobRe.test(block)) { warn(`${label} contains suspicious glob pattern`); } blockLines.forEach((line, lineIdx) => { if (/\t/.test(line)) { fail(`${label} line ${lineIdx + 1} contains tab indentation`); } if (/^ +/.test(line)) { const spaces = line.match(/^ +/)[0].length; if (spaces % 2 !== 0) { warn(`${label} line ${lineIdx + 1} uses odd indentation (${spaces} spaces)`); } } }); }); if (usedSecrets.size > 0) { pass(`found ${usedSecrets.size} workflow secret reference(s)`); } for (const secret of usedSecrets) { if (!documentedSecrets.has(secret)) { fail(`secret used but not documented in a Secrets section: ${secret}`); } } if (failures > 0) { console.log(`[FAIL] workflow snippet validation failed with ${failures} error(s) and ${warnings} warning(s)`); process.exit(1); } if (strict && warnings > 0) { console.log(`[FAIL] workflow snippet strict mode failed on ${warnings} warning(s)`); process.exit(1); } console.log(`[PASS] workflow snippet validation passed with ${warnings} warning(s)`); NODE
-
-
SKILL.md 14.2 KB
--- name: minecraft-ci-release description: "Set up and review CI, artifact publishing, versioning, and release management for Minecraft 26.x or legacy 1.21.x mods and Paper plugins. Use for pipelines and releases, not gameplay implementation or server operations." --- # Minecraft CI / Release Skill ## Routing Boundaries - `Use when`: the task is CI/CD pipelines, artifact publishing, versioning, or release management. - `Do not use when`: the task is gameplay or plugin implementation (`minecraft-modding`, `minecraft-plugin-dev`, or `minecraft-datapack`). - `Do not use when`: the task is server runtime operations or infrastructure tuning (`minecraft-server-admin`). --- ## Release setup Keep each repository's existing release destinations and approval policy. A tag can build an artifact and create a GitHub Release without publishing to Modrinth or CurseForge. Add a publisher only when the project already uses it or the user asks for it. Never add tokens to committed files. For Minecraft 26.x, use Java 25 and state that in workflow labels and examples. Legacy Minecraft 1.21.x projects stay on Java 21; retain their own loader and Gradle conventions instead of mechanically changing their version. Use an immutable GitHub Action revision in a protected workflow. The following refs were verified from the upstream tags on 2026-09-04; refresh them from the upstream tag before intentionally upgrading an action: ```text actions/checkout@v7.0.1 3d3c42e5aac5ba805825da76410c181273ba90b1 actions/setup-java@v6.0.0 dd06d9cba3e5552c54d9f8ea23572deb30010f7c gradle/actions/setup-gradle@v6.3.0 9c971963bec38e04b3d30dcc455b5382be2fdbfb actions/upload-artifact@v7.0.1 043fb46d1a93c77aae656e7c1c64a875d1fc6a0a softprops/action-gh-release@v3.0.3 efb35369e0ad2afab669f228072c1b0d510eae64 ``` ## Version and Tag Convention Minecraft mod versions follow: `{mod_version}+{mc_version}` ``` 1.0.0+26.2 ← mod 1.0.0 for MC 26.2 1.2.3+26.2 2.0.0+26.2 ``` Use a release version without the game suffix for the Git tag, and retain the Minecraft version in the artifact version when the project uses that convention: ```text mod_version: 1.2.3 project/artifact version: 1.2.3+26.2 tag: v1.2.3 ``` The release workflow must verify that `v1.2.3` matches `mod_version=1.2.3` before building. Do not override `mod_version` from the tag, because that hides a mismatch. The Gradle task and safe changelog extraction are in [the publishing reference](references/publishing-gradle.md). --- ## Core CI Workflow (NeoForge + Fabric) This is a Minecraft 26.x / Java 25 example. Its displayed check names are `Build / Build (fabric)`, `Build / Build (neoforge)`, and `Build / Test` after the test job below is added. After the first successful pull request, copy the exact names GitHub displays into branch protection; workflow or job renames change the required-check context. ### `.github/workflows/build.yml` ```yaml name: Build on: push: branches: ["main", "develop"] pull_request: branches: ["main"] permissions: contents: read jobs: build: name: Build (${{ matrix.platform }}) runs-on: ubuntu-latest strategy: matrix: platform: [neoforge, fabric] fail-fast: false steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - name: Set up Java 25 uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c with: java-version: "25" distribution: "temurin" - name: Setup Gradle uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb with: cache-read-only: ${{ github.event_name == 'pull_request' }} - name: Grant execute permission for gradlew run: chmod +x gradlew - name: Build (${{ matrix.platform }}) run: ./gradlew :${{ matrix.platform }}:build --no-daemon - name: Upload artifacts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: name: mod-${{ matrix.platform }}-${{ github.sha }} path: ${{ matrix.platform }}/build/libs/*.jar if-no-files-found: error test: name: Test runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - name: Set up Java 25 uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c with: distribution: temurin java-version: "25" - name: Set up Gradle uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb - name: Run tests run: ./gradlew test --no-daemon ``` --- ## Tagged GitHub Release ### `.github/workflows/release.yml` ```yaml name: Release on: push: tags: - "v*" permissions: contents: write # for creating GitHub releases jobs: release: name: Release runs-on: ubuntu-latest steps: - name: Checkout tagged source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - name: Set up Java 25 uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c with: java-version: "25" distribution: "temurin" - name: Setup Gradle uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb - name: Grant execute permission for gradlew run: chmod +x gradlew - name: Validate tag and build env: MOD_VERSION: ${{ github.ref_name }} run: | MOD_VERSION="${MOD_VERSION#v}" ./gradlew verifyReleaseVersion build --no-daemon \ -PreleaseModVersion="$MOD_VERSION" - name: Select release artifacts env: RELEASE_TAG: ${{ github.ref_name }} run: | mkdir -p release-artifacts shopt -s nullglob select_primary() { local loader="$1"; shift local matches=( "$@" ) if (( ${#matches[@]} != 1 )); then printf 'Expected one %s primary JAR, found %s: %s\n' \ "$loader" "${#matches[@]}" "${matches[*]:-none}" >&2 exit 1 fi cp "${matches[0]}" "release-artifacts/${loader}-${RELEASE_TAG}.jar" } select_primary fabric fabric/build/libs/*-fabric.jar select_primary neoforge neoforge/build/libs/*-neoforge.jar - name: Create GitHub Release uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 with: files: | release-artifacts/fabric-${{ github.ref_name }}.jar release-artifacts/neoforge-${{ github.ref_name }}.jar fail_on_unmatched_files: true generate_release_notes: true prerelease: ${{ contains(github.ref_name, '-alpha') || contains(github.ref_name, '-beta') || contains(github.ref_name, '-rc') }} ``` This workflow creates a GitHub Release only. It expects one primary Fabric JAR and one primary NeoForge JAR with loader-distinct names. Configure those classifiers in the project build, then change both patterns together if its naming convention differs. The selection step fails for zero or multiple matches and copies to distinct release names, preventing accidental overwrite or a partial release. Add a project-specific publisher after version validation only when that destination is in scope. --- ## Paper Plugin CI ### `.github/workflows/build.yml` (plugin) ```yaml name: Build on: push: branches: ["main"] pull_request: jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c with: java-version: "25" distribution: "temurin" - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb - run: chmod +x gradlew - run: ./gradlew build --no-daemon - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: name: plugin-${{ github.sha }} path: build/libs/*.jar if-no-files-found: error test: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c with: java-version: "25" distribution: "temurin" - uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb - run: ./gradlew test --no-daemon ``` --- ## Publishing and Changelog Details Read [the publishing reference](references/publishing-gradle.md) only when the project publishes to Modrinth or CurseForge. It includes current plugin versions, 26.x `jar` selection, explicit legacy Loom `remapJar` guidance, version verification, and a parser that fails when the expected changelog heading is missing. --- ## `gradle.properties` Secrets Pattern Never hardcode tokens. Read them from environment: ```properties # gradle.properties (committed) mod_id=mymod mod_version=1.0.0 minecraft_version=26.2 modrinth_project_id=AABBCCDD curseforge_project_id=123456 # DO NOT commit tokens # Set these as GitHub repo secrets: # MODRINTH_TOKEN, CURSEFORGE_TOKEN ``` --- ## Semantic Versioning for Mods | Change | Version bump | |--------|-------------| | New features, no breaking changes | Minor: `1.1.0` | | Bug fixes only | Patch: `1.0.1` | | API/config breaking changes | Major: `2.0.0` | | Minecraft version update | Keep mod version, change the `+26.2` suffix | | Pre-release | `1.0.0-beta.1`, `1.0.0-rc.1` | --- ## CHANGELOG.md Convention ```markdown # Changelog ## [1.1.0] — 2025-06-01 ### Added - New `/kit` command - PDC-based kill tracker ### Fixed - Death message not appearing on Paper 26.2 ## [1.0.0] — 2025-05-01 ### Added - Initial release ``` The publishing reference extracts this section by `mod_version` and fails clearly when the expected heading is missing. --- ## Dependabot Configuration ### `.github/dependabot.yml` ```yaml version: 2 updates: - package-ecosystem: "gradle" directory: "/" schedule: interval: "weekly" groups: gradle-plugins: patterns: - "com.gradleup.shadow" - "dev.architectury.loom" - "com.modrinth.minotaur" - "net.darkhax.curseforgegradle" - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" ``` --- ## Build Caching Best Practices ```yaml # In all workflow jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb with: # Read-only cache on PRs, read-write on main cache-read-only: ${{ github.event_name == 'pull_request' }} ``` --- ## Branch Protection + Required Checks Recommended GitHub branch protection for `main`: - Require the observed checks: `Build / Build (fabric)`, `Build / Build (neoforge)`, and `Build / Test` - Require linear history (squash/rebase merges) - Require signed commits (optional but recommended for release workflows) --- ## Tag and Release Script ```bash #!/usr/bin/env bash # scripts/release.sh <mod-version> set -euo pipefail VERSION="${1:?Usage: release.sh <mod-version>}" REMOTE="${RELEASE_REMOTE:-origin}" BRANCH="${RELEASE_BRANCH:-main}" EXPECTED_REMOTE_URL="${RELEASE_REMOTE_URL:?Set RELEASE_REMOTE_URL to the expected push URL}" git diff --check [[ -z "$(git status --porcelain=v1 --untracked-files=all)" ]] \ || { echo "Working tree contains staged, unstaged, or untracked files." >&2; exit 1; } [[ "$(git branch --show-current)" == "$BRANCH" ]] \ || { echo "Release must start from branch $BRANCH." >&2; exit 1; } mapfile -t PUSH_URLS < <(git remote get-url --push --all "$REMOTE") [[ ${#PUSH_URLS[@]} -eq 1 && "${PUSH_URLS[0]}" == "$EXPECTED_REMOTE_URL" ]] \ || { echo "Remote $REMOTE must have one expected push URL." >&2; exit 1; } ! git rev-parse --verify --quiet "refs/tags/v${VERSION}" >/dev/null \ || { echo "Local tag v${VERSION} already exists." >&2; exit 1; } set +e git ls-remote --exit-code --tags "$EXPECTED_REMOTE_URL" "refs/tags/v${VERSION}" >/dev/null REMOTE_TAG_STATUS=$? set -e case "$REMOTE_TAG_STATUS" in 0) echo "Remote tag v${VERSION} already exists." >&2; exit 1 ;; 2) ;; *) echo "Could not verify remote tag v${VERSION}." >&2; exit "$REMOTE_TAG_STATUS" ;; esac ./gradlew verifyReleaseVersion --no-daemon -PreleaseModVersion="$VERSION" git tag --annotate "v${VERSION}" --message "Release v${VERSION}" git push "$REMOTE" "HEAD:refs/heads/${BRANCH}" git push "$REMOTE" "refs/tags/v${VERSION}" ``` Update and verify version/changelog files before this script, then tag that release commit. Set `RELEASE_BRANCH`, `RELEASE_REMOTE`, and `RELEASE_REMOTE_URL` for the intended release branch and exact push URL. Do not force-push or retag a published release without explicit authorization. ## Workflow Snippet Validator Run the bundled validator from an installed `minecraft-ci-release` skill directory: ```bash # Run from the installed skill directory: ./scripts/validate-workflow-snippets.sh --root . # Strict mode treats warnings as failures: ./scripts/validate-workflow-snippets.sh --root . --strict ``` The validator is bundled and self-contained. Run it from a copied `.agents/`, `.codex/`, or `.claude/` `minecraft-ci-release` skill directory without relying on repo-root `node_modules`. It validates workflow-shaped YAML, unresolved placeholders, workflow secret documentation, and remote `uses:` references pinned to full commit SHAs. Local actions (`./...`) and Docker actions (`docker://...`) are intentionally excluded from the SHA requirement. It reads only this skill's `SKILL.md`: it does not validate a project's `.github/workflows` files or Gradle tasks. Before a real release, inspect the project's generated artifacts, run `verifyReleaseVersion`, and review the exact workflow diff and configured release destination. --- ## References - [GitHub Actions security hardening](https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-your-deployments) - [GitHub branch protection](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches) - [Minotaur](https://github.com/modrinth/minotaur) - [CurseForgeGradle](https://github.com/Darkhax/CurseForgeGradle)
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.