feat(build): bootstrap Purpur Carry plugin
Release / release (push) Successful in 3m26s

This commit is contained in:
dmg committed 2026-09-22 21:32:51 -04:00
commit 23964f7695
24 files changed
+1126

No files matched your search

+54
View File
@@ -0,0 +1,54 @@
name: CI
on:
push:
branches-ignore: [main]
tags-ignore: ['**']
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout public source without a credential
env:
SOURCE_SHA: ${{ github.sha }}
GIT_TERMINAL_PROMPT: '0'
run: |
printf '%s' "$SOURCE_SHA" | grep -Eq '^[a-f0-9]{40}$'
git init .
git remote add origin https://git.garvis.dev/dmg/purpur-carry.git
git config --local credential.helper ''
git config --local http.extraHeader ''
git config --local http.followRedirects false
git fetch --force --tags origin '+refs/heads/*:refs/remotes/origin/*'
git fetch origin "$SOURCE_SHA"
git checkout --detach "$SOURCE_SHA"
- name: Set up Java
uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4
with:
distribution: temurin
java-version: 25
cache: gradle
- name: Set up Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20
cache: npm
- name: Validate conventional commits
if: github.event_name == 'pull_request'
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.sha }}
run: |
npx -y -p @commitlint/cli -p @commitlint/config-conventional \
commitlint --from "$BASE_SHA" --to "$HEAD_SHA" \
--extends @commitlint/config-conventional
- name: Build and test
run: ./gradlew clean check jar
- name: Upload development artifact
uses: actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 # v3
with:
name: purpur-carry-${{ github.sha }}
path: build/libs/purpur-carry-*.jar
if-no-files-found: error
+114
View File
@@ -0,0 +1,114 @@
name: Release
on:
push:
branches: [main]
concurrency:
group: purpur-carry-release
cancel-in-progress: false
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout public source without a credential
env:
SOURCE_SHA: ${{ github.sha }}
GIT_TERMINAL_PROMPT: '0'
run: |
printf '%s' "$SOURCE_SHA" | grep -Eq '^[a-f0-9]{40}$'
git init .
git remote add origin https://git.garvis.dev/dmg/purpur-carry.git
git config --local credential.helper ''
git config --local http.extraHeader ''
git config --local http.followRedirects false
git fetch --force --tags origin '+refs/heads/*:refs/remotes/origin/*'
git fetch origin "$SOURCE_SHA"
git checkout -B main "$SOURCE_SHA"
- name: Set up Java
uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4
with:
distribution: temurin
java-version: 25
cache: gradle
- name: Set up Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 20
cache: npm
- name: Build and test before release
run: ./gradlew clean check jar
- name: Capture previous tag
id: previous
run: |
previous=$(git describe --tags --abbrev=0 2>/dev/null || true)
printf 'tag=%s\n' "$previous" >> "$GITHUB_OUTPUT"
- name: Compute and push semantic release tag
env:
RELEASE_CREDENTIAL: ${{ secrets.RELEASE_TOKEN }}
REPOSITORY: ${{ github.repository }}
LC_ALL: C
run: |
set +x
test "$REPOSITORY" = dmg/purpur-carry
test -n "$RELEASE_CREDENTIAL" || { printf '%s\n' 'Missing RELEASE_TOKEN.' >&2; exit 1; }
unset GIT_CREDENTIALS GH_TOKEN GITHUB_TOKEN GL_TOKEN GITLAB_TOKEN
unset BB_TOKEN BITBUCKET_TOKEN BB_TOKEN_BASIC_AUTH BITBUCKET_TOKEN_BASIC_AUTH
unset DEBUG NODE_DEBUG GIT_TRACE GIT_TRACE_CURL GIT_CURL_VERBOSE GIT_TRACE_PACKET
unset GIT_TRACE_SETUP GIT_TRACE2 GIT_TRACE2_EVENT GIT_TRACE2_PERF
export GIT_TERMINAL_PROMPT=0
git config --local credential.helper ''
git config --local --add credential.helper "$GITHUB_WORKSPACE/scripts/git-credential.sh"
git config --local credential.useHttpPath true
git config --local http.followRedirects false
npx -y \
-p semantic-release@24.2.9 \
-p @semantic-release/commit-analyzer@13.0.1 \
-p @semantic-release/release-notes-generator@14.1.0 \
semantic-release --branches main \
--repository-url https://git.garvis.dev/dmg/purpur-carry.git \
--plugins @semantic-release/commit-analyzer,@semantic-release/release-notes-generator
- name: Resolve newly created exact-source release
id: release
env:
PREVIOUS_TAG: ${{ steps.previous.outputs.tag }}
EXPECTED_COMMIT: ${{ github.sha }}
run: |
current=$(git describe --tags --exact-match HEAD 2>/dev/null || true)
if [ -n "$current" ] && [ "$current" != "$PREVIOUS_TAG" ]; then
test "$(git rev-parse "${current}^{commit}")" = "$EXPECTED_COMMIT"
printf 'created=true\nversion=%s\n' "${current#v}" >> "$GITHUB_OUTPUT"
else
printf 'created=false\n' >> "$GITHUB_OUTPUT"
fi
- name: Verify versioned plugin and release tooling
if: steps.release.outputs.created == 'true'
env:
VERSION: ${{ steps.release.outputs.version }}
run: ./gradlew clean check jar -PreleaseVersion="$VERSION"
- name: Upload release workflow artifact
if: steps.release.outputs.created == 'true'
uses: actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 # v3
with:
name: purpur-carry-${{ steps.release.outputs.version }}
path: build/libs/purpur-carry-${{ steps.release.outputs.version }}.jar
if-no-files-found: error
- name: Publish verified JAR through a draft release
if: steps.release.outputs.created == 'true'
env:
RELEASE_CREDENTIAL: ${{ secrets.RELEASE_TOKEN }}
RELEASE_COMMIT: ${{ github.sha }}
VERSION: ${{ steps.release.outputs.version }}
PREVIOUS_TAG: ${{ steps.previous.outputs.tag }}
run: |
set +x
if [ -n "$PREVIOUS_TAG" ]; then
RELEASE_NOTES=$(git log --pretty='format:- %s (%h)' "${PREVIOUS_TAG}..HEAD")
else
RELEASE_NOTES=$(git log --pretty='format:- %s (%h)' HEAD)
fi
export RELEASE_NOTES
node build/release-tools/publish-release.js
+10
View File
@@ -0,0 +1,10 @@
.gradle/
build/
out/
.idea/
*.iml
.DS_Store
.env
.env.*
*.log
node_modules/
+9
View File
@@ -0,0 +1,9 @@
# purpur-carry agent entrypoint
The canonical stories, engineering guidance, and **all process documents** are in the private [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/index.md).
Before work, read the sibling `../somc-okf/index.md`, `../somc-okf/processes/index.md`, `../somc-okf/projects/purpur-carry/index.md`, `engineering.md` in that project section, and relevant `../somc-okf/user-stories/purpur-carry/` stories. Also follow the parent workspace `AGENTS.md` when present.
For standalone checkouts, start at the [project page](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-carry/index.md) and [shared process](https://git.garvis.dev/dmg/somc-okf/src/branch/main/processes/development.md). Obtain wiki access before feature work; do not recreate a local knowledge bundle. Source builds do not require private wiki access.
Development follows the [development cycle](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/development-cycle.md): approved stories, failing tests, passing implementation, verification, then source/wiki commit and push. GitOps changes are committed locally **without pushing**; only [Do release](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/do-release.md) authorizes a reviewed GitOps push.
+27
View File
@@ -0,0 +1,27 @@
# Purpur Carry
Purpur Carry is a Purpur 26.2 plugin for native animal and Villager carrying on SoMC. The initial build/release enabler intentionally packages no gameplay; the approved behavior is specified in the [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-carry/index.md).
## Requirements
- Purpur 26.2 build 2618
- Java 25 or newer
- Node.js 20 or newer for release-tool verification
## Build
```bash
./gradlew clean check jar
```
The command compiles with fatal warnings, runs the Java distribution tests and TypeScript release-tool tests, then writes `build/libs/purpur-carry-<version>.jar`. Purpur is compile-only and is not bundled.
## Current behavior
The enabler loads as `PurpurCarry` but registers no command, permission, listener, configuration, persistence, or gameplay behavior. [US-001](https://git.garvis.dev/dmg/somc-okf/src/branch/main/user-stories/purpur-carry/us-001-carry-animals-and-villagers.md) owns `/carry`, `purpurcarry.use`, ordinary-Lead interaction, native passenger carrying, release, and cleanup.
## Releases
Gitea Actions verifies the exact public source revision. Pull requests validate conventional commits; main-branch conventional commits drive semantic releases. Publication uses the repository-scoped `RELEASE_TOKEN` only through an in-memory Git credential helper and authenticated request headers inside the release process, never command arguments. A release is published only after the exact versioned JAR has passed the complete verification suite and uploaded successfully through a draft transaction.
A source release is not installation or deployment approval. GitOps preparation and production release use the separately gated workspace runbooks.
+57
View File
@@ -0,0 +1,57 @@
plugins {
java
}
group = "games.dmg"
version = providers.gradleProperty("releaseVersion")
.orElse("0.1.0-SNAPSHOT")
.get()
repositories {
mavenCentral()
maven("https://repo.purpurmc.org/snapshots/")
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(25)
}
}
tasks.withType<JavaCompile>().configureEach {
options.compilerArgs.addAll(listOf("-Xlint:all,-deprecation,-removal", "-Werror"))
}
dependencies {
compileOnly("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable")
testImplementation("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable")
testImplementation(platform("org.junit:junit-bom:5.13.4"))
testImplementation("org.junit.jupiter:junit-jupiter")
testImplementation("org.yaml:snakeyaml:2.4")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
tasks.test {
useJUnitPlatform()
dependsOn(tasks.jar)
systemProperty("distribution.jar", tasks.jar.get().archiveFile.get().asFile.absolutePath)
systemProperty("distribution.version", project.version.toString())
}
val installReleaseTools = tasks.register<Exec>("installReleaseTools") {
commandLine("npm", "ci", "--ignore-scripts", "--no-audit", "--no-fund")
}
val releaseToolCheck = tasks.register<Exec>("releaseToolCheck") {
dependsOn(installReleaseTools)
commandLine("npm", "test")
}
tasks.check { dependsOn(releaseToolCheck) }
val pluginVersion = version
tasks.processResources {
filesMatching("plugin.yml") {
expand("version" to pluginVersion)
}
}
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+248
View File
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# gradlew start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh gradlew
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored
+82
View File
@@ -0,0 +1,82 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem gradlew startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:execute
@rem Setup the command line
@rem Execute gradlew
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%
+50
View File
@@ -0,0 +1,50 @@
{
"name": "purpur-carry-release-tools",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "purpur-carry-release-tools",
"version": "0.0.0",
"devDependencies": {
"@types/node": "20.19.9",
"typescript": "5.9.3"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@types/node": {
"version": "20.19.9",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.9.tgz",
"integrity": "sha512-cuVNgarYWZqxRJDQHEB58GEONhOK79QVR/qYx4S7kcUObQvUwvFnYxJuuHUKm2aieN9X3yZB4LZsuYNU1Qphsw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "purpur-carry-release-tools",
"version": "0.0.0",
"private": true,
"type": "module",
"engines": { "node": ">=20" },
"scripts": {
"test": "rm -rf build/release-tools && tsc && node --test build/release-tools/*.test.js"
},
"devDependencies": {
"@types/node": "20.19.9",
"typescript": "5.9.3"
}
}
+23
View File
@@ -0,0 +1,23 @@
#!/bin/sh
# Git consumes this protocol over a pipe; never run interactively with a real credential.
set +x
[ "${1-}" = get ] || exit 0
protocol= host= path= username=
while IFS= read -r line && [ -n "$line" ]; do
case "$line" in
protocol=*) protocol=${line#protocol=} ;;
host=*) host=${line#host=} ;;
path=*) path=${line#path=} ;;
username=*) username=${line#username=} ;;
esac
done
[ "$protocol" = https ] || exit 1
[ "$host" = git.garvis.dev ] || exit 1
[ "$path" = dmg/purpur-carry.git ] || exit 1
[ -z "$username" ] || [ "$username" = dmg ] || exit 1
[ -n "${RELEASE_CREDENTIAL-}" ] || exit 1
LC_ALL=C
export LC_ALL
case "$RELEASE_CREDENTIAL" in *[![:graph:]]*) exit 1 ;; esac
printf '%s\n' 'username=dmg'
printf 'password=%s\n\n' "$RELEASE_CREDENTIAL"
+69
View File
@@ -0,0 +1,69 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { resolve } from "node:path";
import { test } from "node:test";
const fixture = "synthetic-git-protocol-fixture";
const helper = resolve("scripts/git-credential.sh");
const request = "protocol=https\nhost=git.garvis.dev\npath=dmg/purpur-carry.git\n\n";
const environment = {
PATH: process.env.PATH,
RELEASE_CREDENTIAL: fixture,
LC_ALL: "C",
GIT_ASKPASS: "echo",
GIT_TERMINAL_PROMPT: "0",
GIT_CONFIG_NOSYSTEM: "1",
GIT_CONFIG_GLOBAL: "/dev/null",
};
test("real Git obtains only the scoped in-memory credential", () => {
const child = spawnSync("git", [
"-c", "credential.helper=",
"-c", `credential.helper=${helper}`,
"-c", "credential.useHttpPath=true",
"credential", "fill",
], { encoding: "utf8", input: request, env: environment });
assert.equal(child.status, 0);
assert.ok(child.stdout.split("\n").includes("username=dmg"));
assert.ok(child.stdout.split("\n").includes(`password=${fixture}`));
assert.equal(child.stderr, "");
});
for (const input of [
request.replace("https", "http"),
request.replace("git.garvis.dev", "evil.invalid"),
request.replace("purpur-carry.git", "another-project.git"),
request.replace("\n\n", "\nusername=other\n\n"),
]) {
test(`refuses mismatched credential scope: ${JSON.stringify(input)}`, () => {
const child = spawnSync("/bin/sh", [helper, "get"], {
encoding: "utf8", input, env: environment,
});
assert.notEqual(child.status, 0);
assert.equal(child.stdout, "");
assert.equal(child.stderr, "");
});
}
for (const action of ["store", "erase"]) {
test(`ignores ${action} without persistence`, () => {
const child = spawnSync("/bin/sh", [helper, action], {
encoding: "utf8", input: request, env: environment,
});
assert.equal(child.status, 0);
assert.equal(child.stdout, "");
assert.equal(child.stderr, "");
});
}
for (const value of ["", "bad\npassword=injected", "trailing-space "]) {
test(`rejects malformed credential input of length ${value.length}`, () => {
const child = spawnSync("/bin/sh", [helper, "get"], {
encoding: "utf8", input: request,
env: { ...environment, RELEASE_CREDENTIAL: value },
});
assert.notEqual(child.status, 0);
assert.equal(child.stdout, "");
assert.equal(child.stderr, "");
});
}
+22
View File
@@ -0,0 +1,22 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { test } from "node:test";
for (const environment of [
{ VERSION: "1.2.3", RELEASE_COMMIT: "a".repeat(40) },
{
VERSION: "../../outside",
RELEASE_CREDENTIAL: "synthetic-cli-fixture",
RELEASE_COMMIT: "a".repeat(40),
},
]) {
test(`CLI fails closed without echoing environment: ${Object.keys(environment)}`, () => {
const child = spawnSync(process.execPath, ["build/release-tools/publish-release.js"], {
encoding: "utf8", env: environment,
});
assert.equal(child.status, 1);
assert.equal(child.stdout, "");
assert.equal(child.stderr,
"Release publication failed; inspect the release state before retrying.\n");
});
}
+24
View File
@@ -0,0 +1,24 @@
import { readFile } from "node:fs/promises";
import { publishRelease } from "./release-client.js";
try {
const version = process.env.VERSION ?? "";
const credential = process.env.RELEASE_CREDENTIAL ?? "";
const commit = process.env.RELEASE_COMMIT ?? "";
if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(version)
|| !credential || !/^[a-f0-9]{40}$/.test(commit)) {
throw new Error("Invalid input");
}
const jar = await readFile(`build/libs/purpur-carry-${version}.jar`);
await publishRelease({
credential,
version,
commit,
jar,
notes: process.env.RELEASE_NOTES ?? "See the source history for changes.",
}, fetch);
console.log(`Published purpur-carry v${version}.`);
} catch {
console.error("Release publication failed; inspect the release state before retrying.");
process.exitCode = 1;
}
+94
View File
@@ -0,0 +1,94 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { publishRelease, type ReleaseInput } from "./release-client.js";
const input: ReleaseInput = {
credential: "synthetic-test-value-not-a-real-credential",
version: "1.2.3",
commit: "a".repeat(40),
notes: "Verified changes",
jar: new Uint8Array([0x50, 0x4b, 0x03, 0x04]),
};
function success(init: RequestInit): Response {
return Response.json({
id: 42,
draft: init.method !== "PATCH",
tag_name: "v1.2.3",
name: "purpur-carry-1.2.3.jar",
size: input.jar.length,
});
}
test("publishes exact bytes through a fixed-repository draft without redirects", async () => {
const calls: { url: string; init: RequestInit }[] = [];
const id = await publishRelease(input, async (url, init) => {
calls.push({ url, init });
return success(init);
});
assert.equal(id, 42);
assert.equal(calls.length, 3);
assert.equal(calls[0]!.url, "https://git.garvis.dev/api/v1/repos/dmg/purpur-carry/releases");
assert.deepEqual(JSON.parse(String(calls[0]!.init.body)), {
tag_name: "v1.2.3",
target_commitish: input.commit,
name: "v1.2.3",
body: input.notes,
draft: true,
prerelease: false,
});
assert.equal(calls[1]!.url,
"https://git.garvis.dev/api/v1/repos/dmg/purpur-carry/releases/42/assets?name=purpur-carry-1.2.3.jar");
assert.deepEqual(new Uint8Array(await new Response(calls[1]!.init.body).arrayBuffer()), input.jar);
assert.equal(calls[2]!.url, "https://git.garvis.dev/api/v1/repos/dmg/purpur-carry/releases/42");
assert.deepEqual(JSON.parse(String(calls[2]!.init.body)), { draft: false });
for (const call of calls) {
assert.equal(call.init.redirect, "error");
assert.equal(new Headers(call.init.headers).get("authorization"), `token ${input.credential}`);
assert.ok(!call.url.includes(input.credential));
}
});
for (const failureStep of [1, 2, 3]) {
for (const status of [302, 401, 409, 500]) {
test(`stops at step ${failureStep} on HTTP ${status} without leaking API bodies`, async () => {
let calls = 0;
await assert.rejects(publishRelease(input, async (_url, init) => {
calls++;
if (calls === failureStep) return new Response(input.credential, { status });
return success(init);
}), (error: unknown) => {
assert.ok(error instanceof Error);
assert.ok(error.message.includes(String(status)));
assert.ok(!error.message.includes(input.credential));
return true;
});
assert.equal(calls, failureStep);
});
}
}
test("sanitizes transport errors", async () => {
await assert.rejects(publishRelease(input, async () => {
throw new Error(`diagnostic containing ${input.credential}`);
}), { message: "Release API request failed." });
});
for (const invalid of [
{ credential: "" },
{ credential: "header\r\ninjection" },
{ version: "../../latest" },
{ version: "1.0.0-SNAPSHOT" },
{ commit: "main" },
{ jar: new Uint8Array() },
]) {
test(`rejects invalid ${Object.keys(invalid)[0]} before network access`, async () => {
let calls = 0;
await assert.rejects(publishRelease({ ...input, ...invalid }, async () => {
calls++;
return Response.json({ id: 42 });
}), { message: "Invalid release input." });
assert.equal(calls, 0);
});
}
+99
View File
@@ -0,0 +1,99 @@
export interface ReleaseInput {
credential: string;
version: string;
commit: string;
notes: string;
jar: Uint8Array;
}
export type Request = (url: string, init: RequestInit) => Promise<Response>;
const releases = "https://git.garvis.dev/api/v1/repos/dmg/purpur-carry/releases";
export async function publishRelease(input: ReleaseInput, request: Request): Promise<number> {
if (!/^[\x21-\x7e]+$/.test(input.credential)
|| !/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(input.version)
|| !/^[a-f0-9]{40}$/.test(input.commit)
|| input.jar.length === 0) {
throw new Error("Invalid release input.");
}
const send: Request = async (url, init) => {
let response: Response;
try {
response = await request(url, { ...init, signal: AbortSignal.timeout(60_000) });
} catch {
throw new Error("Release API request failed.");
}
if (!response.ok) {
throw new Error(`Release API request failed (HTTP ${response.status}).`);
}
return response;
};
const response = await send(releases, {
method: "POST",
redirect: "error",
headers: {
authorization: `token ${input.credential}`,
"content-type": "application/json",
},
body: JSON.stringify({
tag_name: `v${input.version}`,
target_commitish: input.commit,
name: `v${input.version}`,
body: input.notes,
draft: true,
prerelease: false,
}),
});
const release = await readObject(response, "Invalid draft release response.");
if (typeof release.id !== "number" || !Number.isSafeInteger(release.id) || release.id <= 0
|| release.draft !== true || release.tag_name !== `v${input.version}`) {
throw new Error("Invalid draft release response.");
}
const assetName = `purpur-carry-${input.version}.jar`;
const assetResponse = await send(`${releases}/${release.id}/assets?name=${assetName}`, {
method: "POST",
redirect: "error",
headers: {
authorization: `token ${input.credential}`,
"content-type": "application/octet-stream",
},
body: new Uint8Array(input.jar).buffer,
});
const asset = await readObject(assetResponse, "Invalid release asset response.");
if (asset.name !== assetName || asset.size !== input.jar.length) {
throw new Error("Invalid release asset response.");
}
const publishedResponse = await send(`${releases}/${release.id}`, {
method: "PATCH",
redirect: "error",
headers: {
authorization: `token ${input.credential}`,
"content-type": "application/json",
},
body: JSON.stringify({ draft: false }),
});
const published = await readObject(
publishedResponse, "Invalid published release response.");
if (published.id !== release.id || published.draft !== false
|| published.tag_name !== `v${input.version}`) {
throw new Error("Invalid published release response.");
}
return release.id;
}
async function readObject(
response: Response,
message: string,
): Promise<Record<string, unknown>> {
let value: unknown;
try {
value = await response.json();
} catch {
throw new Error(message);
}
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(message);
}
return value as Record<string, unknown>;
}
+5
View File
@@ -0,0 +1,5 @@
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}
rootProject.name = "purpur-carry"
@@ -0,0 +1,10 @@
package games.dmg.purpurcarry;
import org.bukkit.plugin.java.JavaPlugin;
public final class PurpurCarryPlugin extends JavaPlugin {
@Override
public void onEnable() {
getLogger().info("Purpur Carry enabled.");
}
}
+6
View File
@@ -0,0 +1,6 @@
name: PurpurCarry
version: ${version}
main: games.dmg.purpurcarry.PurpurCarryPlugin
api-version: "1.20"
description: Native animal and Villager carrying for SoMC.
author: dmg.games
@@ -0,0 +1,47 @@
package games.dmg.purpurcarry;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.DataInputStream;
import java.nio.file.Path;
import java.util.Map;
import java.util.jar.JarFile;
import org.bukkit.plugin.java.JavaPlugin;
import org.junit.jupiter.api.Test;
import org.yaml.snakeyaml.Yaml;
final class PurpurCarryDistributionTest {
@Test
void packagesTheApprovedLoadableJava25PluginIdentity() throws Exception {
Path artifact = Path.of(System.getProperty("distribution.jar"));
String version = System.getProperty("distribution.version");
assertEquals("purpur-carry-" + version + ".jar", artifact.getFileName().toString());
try (var jar = new JarFile(artifact.toFile())) {
var descriptor = jar.getJarEntry("plugin.yml");
assertNotNull(descriptor);
Map<?, ?> metadata = new Yaml().load(jar.getInputStream(descriptor));
assertEquals("PurpurCarry", metadata.get("name"));
assertEquals(version, metadata.get("version"));
assertEquals("games.dmg.purpurcarry.PurpurCarryPlugin", metadata.get("main"));
assertEquals("1.20", metadata.get("api-version"));
assertFalse(metadata.containsKey("commands"));
assertFalse(metadata.containsKey("permissions"));
Class<?> entrypoint = Class.forName((String) metadata.get("main"));
assertTrue(JavaPlugin.class.isAssignableFrom(entrypoint));
var bytecode = jar.getJarEntry("games/dmg/purpurcarry/PurpurCarryPlugin.class");
assertNotNull(bytecode);
try (var input = new DataInputStream(jar.getInputStream(bytecode))) {
assertEquals(0xCAFEBABE, input.readInt());
input.readUnsignedShort();
assertEquals(69, input.readUnsignedShort());
}
assertFalse(jar.stream().anyMatch(entry ->
entry.getName().startsWith("org/bukkit/")
|| entry.getName().startsWith("org/purpurmc/")
|| entry.getName().endsWith("Test.class")));
}
}
}
@@ -0,0 +1,40 @@
package games.dmg.purpurcarry;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
final class WorkflowContractTest {
@Test
void ciBuildsAndUploadsACommitAddressedDevelopmentJar() throws Exception {
String workflow = Files.readString(Path.of(".gitea/workflows/ci.yml"));
assertTrue(workflow.contains("pull_request:"));
assertTrue(workflow.contains("java-version: 25"));
assertTrue(workflow.contains("./gradlew clean check jar"));
assertTrue(workflow.contains("commitlint"));
assertTrue(workflow.contains("purpur-carry-${{ github.sha }}"));
assertTrue(workflow.contains("build/libs/purpur-carry-*.jar"));
}
@Test
void releaseVerifiesExactPublicSourceAndPublishesWithoutCredentialArguments() throws Exception {
String workflow = Files.readString(Path.of(".gitea/workflows/release.yml"));
assertTrue(workflow.contains("SOURCE_SHA: ${{ github.sha }}"));
assertTrue(workflow.contains("https://git.garvis.dev/dmg/purpur-carry.git"));
assertTrue(workflow.contains("./gradlew clean check jar"));
assertTrue(workflow.contains("semantic-release@24.2.9"));
assertTrue(workflow.contains("git-credential.sh"));
assertTrue(workflow.contains("RELEASE_CREDENTIAL: ${{ secrets.RELEASE_TOKEN }}"));
assertTrue(workflow.contains("RELEASE_COMMIT: ${{ github.sha }}"));
assertTrue(workflow.contains("node build/release-tools/publish-release.js"));
assertTrue(workflow.contains("build/libs/purpur-carry-${{ steps.release.outputs.version }}.jar"));
assertFalse(workflow.contains("Authorization: token"));
assertFalse(workflow.contains("GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}"));
assertFalse(workflow.contains("token: ${{ secrets.RELEASE_TOKEN }}"));
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noUncheckedIndexedAccess": true,
"outDir": "build/release-tools",
"rootDir": "scripts",
"types": ["node"],
"lib": ["ES2022", "DOM"],
"noEmitOnError": true
},
"include": ["scripts/*.ts"]
}