commit 05f9627cf9040034d97531474b6de1c3a997efc6 Author: Dylan Garvis Date: Wed Sep 16 21:22:01 2026 -0400 build(fantasy-stick): bootstrap tested plugin and release tools diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..bf92232 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,51 @@ +name: CI + +on: + push: + 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-fantasy-stick.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-fantasy-stick-${{ github.sha }} + path: build/libs/purpur-fantasy-stick-*.jar + if-no-files-found: error diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..9faab1b --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,115 @@ +name: Release + +on: + push: + branches: [main] + +concurrency: + group: fantasy-stick-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-fantasy-stick.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-fantasy-stick + test -n "$RELEASE_CREDENTIAL" || { printf '%s\n' 'Missing RELEASE_TOKEN.' >&2; exit 1; } + # Prevent semantic-release from embedding any recognized token in a Git URL/argv. + 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_ASKPASS="$GITHUB_WORKSPACE/scripts/git-askpass.sh" + export GIT_TERMINAL_PROMPT=0 + git config --local credential.helper '' + git config --local credential.useHttpPath false + 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-fantasy-stick.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-fantasy-stick-${{ steps.release.outputs.version }} + path: build/libs/purpur-fantasy-stick-${{ 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..6e2401c --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.gradle/ +build/ +.idea/ +*.iml +.DS_Store +.env +.env.* +*.log +node_modules/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0778562 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,11 @@ +# purpur-fantasy-stick agent entrypoint + +The canonical stories, engineering guidance, and process documents live 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-fantasy-stick/index.md`, that project's `engineering.md`, and relevant `../somc-okf/user-stories/purpur-fantasy-stick/` stories. Follow the parent workspace `AGENTS.md` when present. Do not invent defaults for explicitly unresolved gameplay or recovery decisions. + +For standalone checkouts, obtain access to the [project page](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-fantasy-stick/index.md) and [development process](https://git.garvis.dev/dmg/somc-okf/src/branch/main/processes/development.md). Do not create a parallel knowledge bundle. Source builds do not require private wiki access. + +Follow the [development cycle](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/development-cycle.md): approved stories, meaningful failing tests, minimal passing implementation, immediate criterion evidence, full verification, and source/wiki publication. GitOps preparation is separately approved and committed locally without pushing. Only [Do release](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/do-release.md) authorizes a reviewed production push. + +Use Java 25 and the declared Purpur API. Run `./gradlew clean check jar`. Preserve runtime identity, persistent namespaces, and unrelated player/plugin state. Never expose release credentials in arguments, output, or Git. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c657a9f --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +# Fantasy Stick + +**Bootstrap only. Gameplay is not implemented and this JAR is not ready for deployment.** + +Fantasy Stick is the proposed transferable-character RPG plugin for Social Minecraft. Its approved design uses Paper character sheets, native equipment storage and a book/basic-stick/seven-ability hotbar. These are planned features, not claims about this build. + +## Build and verify + +- Java 25 toolchain (Gradle can resolve it), Gradle wrapper 9.6.1. +- Purpur API/runtime `26.2.build.2618-stable`; `plugin.yml` declares API `26.2`. +- Node.js 20 or later and npm for the TypeScript release-tool tests. + +```sh +./gradlew clean check jar +./gradlew check jar -PreleaseVersion=0.1.0 +``` + +`check` runs Java packaging tests, TypeScript release-client/credential-helper tests, and a native compatibility test against the checksum-verified Purpur runtime. Native preparation downloads and patches the declared runtime **without starting a listening server or creating a gameplay world**. A clean build repeats that preparation. Dependencies and runtime downloads require network access. + +Build output is `build/libs/purpur-fantasy-stick-.jar`. Server APIs, tests, credentials, and Node tooling are not bundled in the plugin. Runtime identity is `FantasyStick`, entrypoint `games.dmg.fantasystick.FantasyStickPlugin`, data folder `FantasyStick`, namespace `fantasystick`. Bootstrap registers no commands, permissions, recipes, sessions or gameplay listeners. + +## CI and publication + +Gitea CI verifies pushes/PRs; main pushes run semantic-release and can publish immutable versioned JARs. Source checkout is anonymous from the public repository; external action revisions are pinned to full commit SHAs. CI and actual release publication must both be verified for the exact source commit before any deployment preparation. The first source push is currently gated on approved `RELEASE_TOKEN` provisioning. No remote workflow has run yet. + +The release credential must be a repository-write credential authorized for `dmg/purpur-fantasy-stick`. Provision it through the approved protected-file/stdin workflow; never paste it into chat, commit it, or supply it as a command argument. + +- Git receives the credential through the restricted `git-askpass.sh` pipe, for HTTPS prompts on `git.garvis.dev` as `dmg` only. Recognized semantic-release token environment names are unset so they cannot become credential-bearing Git URLs. Git HTTP redirects and credential-helper storage are disabled. +- API upload uses an in-process TypeScript client, a fixed repository URL, timeouts, redirect refusal and sanitized failures. Credentials are read from the release step's environment, never arguments or generated request files. +- Publication creates a draft, uploads/checks the expected asset name and size, then publishes. Failed calls are not automatically retried, existing releases/assets are not overwritten, and raw errors/response bodies are not logged. +- A failed run may leave a tag or draft. Inspect server-side state before proposing an approved recovery; rerunning does not imply a new release and must not overwrite an immutable asset. Verify downloaded bytes/checksum and embedded JAR version separately after publication. +- Release-client tests use synthetic credentials and stubbed HTTP responses. They do **not** prove a real token works or a release exists. + +## Canonical knowledge and delivery + +Stories, design, open decisions and evidence are in the private [SoMC wiki project](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-fantasy-stick/index.md). See [AGENTS.md](AGENTS.md) for the shared process. Source builds do not need wiki access. + +No inventory recovery guarantees, gameplay balance, multiplayer behavior, or production compatibility have been verified by this bootstrap. Production rollout and GitOps changes remain separately approved; the development cycle never pushes GitOps. diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..35b4bfd --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,109 @@ +import java.net.URI +import java.security.MessageDigest + +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") + 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()) +} + +// Patch the exact supported runtime for non-listening native API tests, as in Purpur Base. +val nativeRuntime = layout.buildDirectory.dir("native-runtime") +val downloadNativeRuntime = tasks.register("downloadNativeRuntime") { + val launcher = nativeRuntime.map { it.file("purpur-26.2-2618.jar") } + outputs.file(launcher) + doLast { + val file = launcher.get().asFile + file.parentFile.mkdirs() + val connection = URI("https://api.purpurmc.org/v2/purpur/26.2/2618/download").toURL().openConnection() + connection.connectTimeout = 30_000 + connection.readTimeout = 120_000 + val bytes = connection.getInputStream().use { it.readBytes() } + val digest = MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) } + check(digest == "4a32d046a118804d89ca74ba89b798c98f6d8d1f310c18077ac573597049de31") { + "Purpur 2618 checksum mismatch" + } + file.writeBytes(bytes) + } +} +val prepareNativeRuntime = tasks.register("prepareNativeRuntime") { + dependsOn(downloadNativeRuntime) + javaLauncher = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(25) } + classpath = files(nativeRuntime.map { it.file("purpur-26.2-2618.jar") }) + mainClass = "io.papermc.paperclip.Main" + jvmArgs("-Dpaperclip.patchonly=true") + workingDir(nativeRuntime) + outputs.dir(nativeRuntime.map { it.dir("versions") }) + outputs.dir(nativeRuntime.map { it.dir("libraries") }) +} +val nativeTest = sourceSets.create("nativeTest") +dependencies { + add(nativeTest.implementationConfigurationName, platform("org.junit:junit-bom:5.13.4")) + add(nativeTest.implementationConfigurationName, "org.junit.jupiter:junit-jupiter") + add(nativeTest.compileOnlyConfigurationName, "org.jetbrains:annotations:26.0.2") + add(nativeTest.compileOnlyConfigurationName, "org.checkerframework:checker-qual:3.49.2") + add(nativeTest.runtimeOnlyConfigurationName, "org.junit.platform:junit-platform-launcher") +} +val nativeRuntimeJars = files(fileTree(nativeRuntime) { + include("versions/**/*.jar", "libraries/**/*.jar") +}).builtBy(prepareNativeRuntime) +nativeTest.compileClasspath += sourceSets.main.get().output + nativeRuntimeJars +nativeTest.runtimeClasspath += sourceSets.main.get().output + nativeRuntimeJars +val nativeCompatibilityTest = tasks.register("nativeCompatibilityTest") { + description = "Verifies packaged metadata against the actual Purpur runtime without starting a server" + dependsOn(tasks.jar) + testClassesDirs = nativeTest.output.classesDirs + classpath = nativeTest.runtimeClasspath + useJUnitPlatform() + maxHeapSize = "1G" + workingDir(nativeRuntime) + systemProperty("distribution.jar", tasks.jar.get().archiveFile.get().asFile.absolutePath) +} +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(nativeCompatibilityTest, releaseToolCheck) } + +val pluginVersion = version + +tasks.processResources { + inputs.property("pluginVersion", pluginVersion) + 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..9b1d063 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,50 @@ +{ + "name": "purpur-fantasy-stick-release-tools", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "purpur-fantasy-stick-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..a1f1fe1 --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "purpur-fantasy-stick-release-tools", + "version": "0.0.0", + "private": true, + "type": "module", + "engines": { "node": ">=20" }, + "scripts": { + "test": "tsc && node --test build/release-tools/*.test.js" + }, + "devDependencies": { + "@types/node": "20.19.9", + "typescript": "5.9.3" + } +} diff --git a/scripts/git-askpass.sh b/scripts/git-askpass.sh new file mode 100755 index 0000000..adcc589 --- /dev/null +++ b/scripts/git-askpass.sh @@ -0,0 +1,13 @@ +#!/bin/sh +# Git captures stdout through its askpass pipe. Never invoke with a credential argument. +set +x +case "${1-}" in + "Username for 'https://git.garvis.dev':"|"Username for 'https://git.garvis.dev': ") + printf '%s\n' dmg + ;; + "Password for 'https://dmg@git.garvis.dev':"|"Password for 'https://dmg@git.garvis.dev': ") + test -n "${RELEASE_CREDENTIAL-}" || exit 1 + printf '%s\n' "$RELEASE_CREDENTIAL" + ;; + *) exit 1 ;; +esac diff --git a/scripts/git-askpass.test.ts b/scripts/git-askpass.test.ts new file mode 100644 index 0000000..abe781a --- /dev/null +++ b/scripts/git-askpass.test.ts @@ -0,0 +1,41 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { test } from "node:test"; + +const credential = "synthetic-askpass-fixture"; +function ask(prompt: string, value = credential) { + return spawnSync("/bin/sh", ["scripts/git-askpass.sh", prompt], { + encoding: "utf8", env: { RELEASE_CREDENTIAL: value, LC_ALL: "C" }, + }); +} + +test("gives Git its username and password over captured stdout, never credential argv", () => { + for (const suffix of ["", " "]) { + const username = ask(`Username for 'https://git.garvis.dev':${suffix}`); + const password = ask(`Password for 'https://dmg@git.garvis.dev':${suffix}`); + assert.equal(username.status, 0); + assert.equal(username.stdout, "dmg\n"); + assert.equal(password.status, 0); + assert.equal(password.stdout, `${credential}\n`); + assert.equal(password.stderr, ""); + } +}); + +for (const prompt of [ + "Password for 'https://dmg@git.garvis.dev.evil.invalid': ", + "Password for 'http://dmg@git.garvis.dev': ", + "Password for 'https://other@git.garvis.dev': ", "unexpected prompt", +]) { + test(`refuses non-approved authentication prompts: ${prompt}`, () => { + const result = ask(prompt); + assert.notEqual(result.status, 0); + assert.equal(result.stdout, ""); + assert.equal(result.stderr, ""); + }); +} + +test("missing release credential fails without falling back to another secret", () => { + const result = ask("Password for 'https://dmg@git.garvis.dev': ", ""); + assert.notEqual(result.status, 0); + assert.equal(result.stdout, ""); +}); diff --git a/scripts/publish-release.test.ts b/scripts/publish-release.test.ts new file mode 100644 index 0000000..a85c837 --- /dev/null +++ b/scripts/publish-release.test.ts @@ -0,0 +1,17 @@ +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 on invalid input 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..ac3803a --- /dev/null +++ b/scripts/publish-release.ts @@ -0,0 +1,21 @@ +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 ?? ""; + // Check path components and required inputs before opening an artifact or contacting Gitea. + 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-fantasy-stick-${version}.jar`); + await publishRelease({ + credential, version, commit, jar, + notes: process.env.RELEASE_NOTES ?? "See the source history for changes.", + }, fetch); + console.log(`Published purpur-fantasy-stick v${version}.`); +} catch { + // Never echo credentials, upstream error bodies, transport diagnostics or the environment. + 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..ada692c --- /dev/null +++ b/scripts/release-client.test.ts @@ -0,0 +1,127 @@ +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-fantasy-stick-1.2.3.jar", size: input.jar.length, + }); +} + +test("creates a draft on the fixed repository and exact source commit, without following redirects", async () => { + const calls: { url: string; init: RequestInit }[] = []; + const id = await publishRelease(input, async (url, init) => { + calls.push({ url, init }); + return success(init); + }); + assert.ok(calls.length >= 1); + const first = calls[0]!; + assert.equal(first.url, "https://git.garvis.dev/api/v1/repos/dmg/purpur-fantasy-stick/releases"); + assert.equal(first.init.method, "POST"); + assert.equal(first.init.redirect, "error"); + assert.equal(new Headers(first.init.headers).get("authorization"), `token ${input.credential}`); + assert.deepEqual(JSON.parse(String(first.init.body)), { + tag_name: "v1.2.3", target_commitish: input.commit, name: "v1.2.3", + body: input.notes, draft: true, prerelease: false, + }); + assert.equal(id, 42); +}); + +test("uploads the exact versioned bytes before publishing the draft", async () => { + const calls: { url: string; init: RequestInit }[] = []; + await publishRelease(input, async (url, init) => { + calls.push({ url, init }); + return success(init); + }); + assert.equal(calls.length, 3); + assert.equal(calls[1]!.url, + "https://git.garvis.dev/api/v1/repos/dmg/purpur-fantasy-stick/releases/42/assets?name=purpur-fantasy-stick-1.2.3.jar"); + assert.equal(calls[1]!.init.method, "POST"); + assert.deepEqual(new Uint8Array(await new Response(calls[1]!.init.body).arrayBuffer()), input.jar); + assert.equal(new Headers(calls[1]!.init.headers).get("content-type"), "application/octet-stream"); + assert.equal(calls[2]!.init.method, "PATCH"); + assert.equal(calls[2]!.url, "https://git.garvis.dev/api/v1/repos/dmg/purpur-fantasy-stick/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 error 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, "Do not retry, overwrite an asset or publish after a failed step"); + }); + } +} + +test("sanitizes transport errors and does not follow a redirect with the credential", 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 any network request`, 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); + }); +} + +for (const body of ["not JSON", "null", JSON.stringify({ id: -1 }), JSON.stringify({ id: "../other" }), + JSON.stringify({ id: 42, draft: false, tag_name: "v1.2.3" })]) { + test(`rejects malformed or already-public draft response: ${body}`, async () => { + let calls = 0; + await assert.rejects(publishRelease(input, async () => { + calls++; + return new Response(body); + }), { message: "Invalid draft release response." }); + assert.equal(calls, 1); + }); +} + +for (const [failureStep, body] of [ + [2, {}], [2, { name: "other.jar", size: 4 }], + [2, { name: "purpur-fantasy-stick-1.2.3.jar", size: 0 }], + [3, { id: 42, draft: true, tag_name: "v1.2.3" }], + [3, { id: 77, draft: false, tag_name: "v1.2.3" }], + [3, { id: 42, draft: false, tag_name: "v8.8.8" }], +] as const) { + test(`requires a matching asset and completed publication: ${JSON.stringify(body)}`, async () => { + let calls = 0; + await assert.rejects(publishRelease(input, async (_url, init) => { + calls++; + return calls === failureStep ? Response.json(body) : success(init); + }), { message: failureStep === 2 ? "Invalid release asset response." : "Invalid published release response." }); + assert.equal(calls, failureStep); + }); +} diff --git a/scripts/release-client.ts b/scripts/release-client.ts new file mode 100644 index 0000000..fcda323 --- /dev/null +++ b/scripts/release-client.ts @@ -0,0 +1,73 @@ +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-fantasy-stick/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 assetResponse = await send(`${releases}/${release.id}/assets?name=purpur-fantasy-stick-${input.version}.jar`, { + 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 !== `purpur-fantasy-stick-${input.version}.jar` || 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..d610183 --- /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-fantasy-stick" diff --git a/src/main/java/games/dmg/fantasystick/FantasyStickPlugin.java b/src/main/java/games/dmg/fantasystick/FantasyStickPlugin.java new file mode 100644 index 0000000..fdb1ef1 --- /dev/null +++ b/src/main/java/games/dmg/fantasystick/FantasyStickPlugin.java @@ -0,0 +1,7 @@ +package games.dmg.fantasystick; + +import org.bukkit.plugin.java.JavaPlugin; + +/** Build foundation only; gameplay is not yet registered or ready for deployment. */ +public final class FantasyStickPlugin extends JavaPlugin { +} diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml new file mode 100644 index 0000000..199d345 --- /dev/null +++ b/src/main/resources/plugin.yml @@ -0,0 +1,6 @@ +name: FantasyStick +version: ${version} +main: games.dmg.fantasystick.FantasyStickPlugin +api-version: "26.2" +description: Transferable RPG characters for SoMC; bootstrap only, gameplay not implemented. +author: dmg.games diff --git a/src/nativeTest/java/games/dmg/fantasystick/NativeCompatibilityTest.java b/src/nativeTest/java/games/dmg/fantasystick/NativeCompatibilityTest.java new file mode 100644 index 0000000..47565f2 --- /dev/null +++ b/src/nativeTest/java/games/dmg/fantasystick/NativeCompatibilityTest.java @@ -0,0 +1,24 @@ +package games.dmg.fantasystick; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.file.Path; +import java.util.jar.JarFile; +import org.bukkit.craftbukkit.util.ApiVersion; +import org.bukkit.plugin.PluginDescriptionFile; +import org.junit.jupiter.api.Test; + +/** Uses the real patched server classes, not a mocked server version or a running world. */ +class NativeCompatibilityTest { + @Test + void packagedApiContractMatchesSupportedPurpurRuntime() throws Exception { + try (var jar = new JarFile(Path.of(System.getProperty("distribution.jar")).toFile()); + var descriptor = jar.getInputStream(jar.getJarEntry("plugin.yml"))) { + var metadata = new PluginDescriptionFile(descriptor); + assertEquals("26.2", metadata.getAPIVersion()); + assertEquals(ApiVersion.CURRENT, ApiVersion.getOrCreateVersion(metadata.getAPIVersion())); + assertEquals("games.dmg.fantasystick.FantasyStickPlugin", metadata.getMain()); + assertTrue(org.bukkit.plugin.java.JavaPlugin.class.isAssignableFrom(FantasyStickPlugin.class)); + } + } +} diff --git a/src/test/java/games/dmg/fantasystick/DistributionTest.java b/src/test/java/games/dmg/fantasystick/DistributionTest.java new file mode 100644 index 0000000..c0cfc70 --- /dev/null +++ b/src/test/java/games/dmg/fantasystick/DistributionTest.java @@ -0,0 +1,45 @@ +package games.dmg.fantasystick; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.DataInputStream; +import java.nio.file.Path; +import java.util.jar.JarFile; +import org.bukkit.plugin.PluginDescriptionFile; +import org.bukkit.plugin.java.JavaPlugin; +import org.junit.jupiter.api.Test; + +class DistributionTest { + @Test + void packagesAnExpandedNativePluginDescriptorAndJava25Entrypoint() throws Exception { + Path artifact = Path.of(System.getProperty("distribution.jar")); + String version = System.getProperty("distribution.version"); + assertEquals("purpur-fantasy-stick-" + version + ".jar", artifact.getFileName().toString()); + try (var jar = new JarFile(artifact.toFile())) { + var descriptor = jar.getJarEntry("plugin.yml"); + assertNotNull(descriptor, "The distribution needs a native plugin descriptor"); + PluginDescriptionFile metadata; + try (var input = jar.getInputStream(descriptor)) { + metadata = new PluginDescriptionFile(input); + } + assertEquals("FantasyStick", metadata.getName()); + assertEquals(version, metadata.getVersion()); + assertFalse(metadata.getVersion().contains("${")); + assertEquals("games.dmg.fantasystick.FantasyStickPlugin", metadata.getMain()); + assertNotNull(metadata.getAPIVersion(), "Declare the native API compatibility contract"); + assertTrue(metadata.getCommands().isEmpty(), "Admin commands are not yet approved"); + assertTrue(metadata.getPermissions().isEmpty()); + assertTrue(JavaPlugin.class.isAssignableFrom(FantasyStickPlugin.class)); + var entrypoint = jar.getJarEntry(metadata.getMain().replace('.', '/') + ".class"); + assertNotNull(entrypoint); + try (var bytecode = new DataInputStream(jar.getInputStream(entrypoint))) { + assertEquals(0xCAFEBABE, bytecode.readInt()); + bytecode.readUnsignedShort(); + assertEquals(69, bytecode.readUnsignedShort(), "Java 25 bytecode"); + } + assertFalse(jar.stream().anyMatch(entry -> entry.getName().startsWith("org/") + || entry.getName().startsWith("net/") || entry.getName().endsWith("Test.class")), + "Server API and test dependencies must not ship in the plugin"); + } + } +} 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"] +}