commit 23964f76959ee344fe4ca5ace40b454e9166cd70 Author: Dylan Garvis Date: Tue Sep 22 21:32:51 2026 -0400 feat(build): bootstrap Purpur Carry plugin diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..0677ee0 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -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 diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..8514291 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4e55b90 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.gradle/ +build/ +out/ +.idea/ +*.iml +.DS_Store +.env +.env.* +*.log +node_modules/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e203995 --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ef3f860 --- /dev/null +++ b/README.md @@ -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-.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. diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..40b72da --- /dev/null +++ b/build.gradle.kts @@ -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().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("installReleaseTools") { + commandLine("npm", "ci", "--ignore-scripts", "--no-audit", "--no-fund") +} +val releaseToolCheck = tasks.register("releaseToolCheck") { + dependsOn(installReleaseTools) + commandLine("npm", "test") +} +tasks.check { dependsOn(releaseToolCheck) } + +val pluginVersion = version + +tasks.processResources { + filesMatching("plugin.yml") { + expand("version" to pluginVersion) + } +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..b1b8ef5 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..a351597 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..203529c --- /dev/null +++ b/gradlew @@ -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" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..7e60b72 --- /dev/null +++ b/gradlew.bat @@ -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% diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..1722cad --- /dev/null +++ b/package-lock.json @@ -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" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..1eb4ff6 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/scripts/git-credential.sh b/scripts/git-credential.sh new file mode 100755 index 0000000..fa5a347 --- /dev/null +++ b/scripts/git-credential.sh @@ -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" diff --git a/scripts/git-credential.test.ts b/scripts/git-credential.test.ts new file mode 100644 index 0000000..739b69d --- /dev/null +++ b/scripts/git-credential.test.ts @@ -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, ""); + }); +} diff --git a/scripts/publish-release.test.ts b/scripts/publish-release.test.ts new file mode 100644 index 0000000..dbffad1 --- /dev/null +++ b/scripts/publish-release.test.ts @@ -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"); + }); +} diff --git a/scripts/publish-release.ts b/scripts/publish-release.ts new file mode 100644 index 0000000..a9e0d66 --- /dev/null +++ b/scripts/publish-release.ts @@ -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; +} diff --git a/scripts/release-client.test.ts b/scripts/release-client.test.ts new file mode 100644 index 0000000..3e5d74c --- /dev/null +++ b/scripts/release-client.test.ts @@ -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); + }); +} diff --git a/scripts/release-client.ts b/scripts/release-client.ts new file mode 100644 index 0000000..c440ced --- /dev/null +++ b/scripts/release-client.ts @@ -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; + +const releases = "https://git.garvis.dev/api/v1/repos/dmg/purpur-carry/releases"; + +export async function publishRelease(input: ReleaseInput, request: Request): Promise { + 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> { + 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; +} diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..f45b3e0 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,5 @@ +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} + +rootProject.name = "purpur-carry" diff --git a/src/main/java/games/dmg/purpurcarry/PurpurCarryPlugin.java b/src/main/java/games/dmg/purpurcarry/PurpurCarryPlugin.java new file mode 100644 index 0000000..39480fa --- /dev/null +++ b/src/main/java/games/dmg/purpurcarry/PurpurCarryPlugin.java @@ -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."); + } +} diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml new file mode 100644 index 0000000..5aaec3a --- /dev/null +++ b/src/main/resources/plugin.yml @@ -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 diff --git a/src/test/java/games/dmg/purpurcarry/PurpurCarryDistributionTest.java b/src/test/java/games/dmg/purpurcarry/PurpurCarryDistributionTest.java new file mode 100644 index 0000000..91fd060 --- /dev/null +++ b/src/test/java/games/dmg/purpurcarry/PurpurCarryDistributionTest.java @@ -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"))); + } + } +} diff --git a/src/test/java/games/dmg/purpurcarry/WorkflowContractTest.java b/src/test/java/games/dmg/purpurcarry/WorkflowContractTest.java new file mode 100644 index 0000000..25d2fa1 --- /dev/null +++ b/src/test/java/games/dmg/purpurcarry/WorkflowContractTest.java @@ -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 }}")); + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..3a627c4 --- /dev/null +++ b/tsconfig.json @@ -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"] +}