From 37fd6e486a00b1c895e8ee2dd3ce59e1ecc470b7 Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Fri, 21 Aug 2026 14:35:17 +0200 Subject: [PATCH] fix(release): publish plugin config image --- .github/workflows/release.yml | 22 +++- Dockerfile | 35 +++++ build.gradle.kts | 33 +++++ ...8-20-plugin-config-oci-stage-dependency.md | 124 ++++++++++++++++++ release-please-config.json | 11 +- velocity/build.gradle.kts | 18 ++- .../config/ReleaseArtifactContractTest.kt | 96 ++++++++++++++ version.txt | 1 + 8 files changed, 329 insertions(+), 11 deletions(-) create mode 100644 Dockerfile create mode 100644 docs/superpowers/plans/2026-08-20-plugin-config-oci-stage-dependency.md create mode 100644 velocity/src/test/kotlin/gg/grounds/config/ReleaseArtifactContractTest.kt create mode 100644 version.txt diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5b0531c..754858f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,13 +2,21 @@ name: Release on: push: - tags: - - "*" - -permissions: - contents: write - packages: write + tags: [v*] jobs: - reusable: + maven: + permissions: + contents: read + packages: write + secrets: + PACKAGES_TOKEN: ${{ secrets.GROUNDS_PACKAGES_TOKEN }} uses: groundsgg/.github/.github/workflows/gradle-publish.yml@main + + docker: + permissions: + contents: write + packages: write + secrets: + PACKAGES_TOKEN: ${{ secrets.GROUNDS_PACKAGES_TOKEN }} + uses: groundsgg/.github/.github/workflows/docker-gradle-build-push.yml@main diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..133405b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +# syntax=docker/dockerfile:1 +# +# The plugin-velocity-jar chart copies this data-only image's final shaded +# Velocity plugin from /jar/plugin.jar. GitHub Packages credentials arrive as +# a BuildKit secret and are never retained in an image layer. + +FROM eclipse-temurin:25-jdk AS build +WORKDIR /src + +ARG GITHUB_USER + +COPY gradle/ gradle/ +COPY gradlew settings.gradle.kts build.gradle.kts version.txt ./ +COPY common/ common/ +COPY velocity/ velocity/ + +RUN mkdir -p paper example-paper example-velocity + +RUN --mount=type=secret,id=github_token,required=true \ + /bin/sh -euc '\ + : "${GITHUB_USER:?GITHUB_USER build arg is required}"; \ + token="$(cat /run/secrets/github_token)"; \ + ./gradlew --no-daemon :velocity:shadowJar \ + -Pgithub.user="${GITHUB_USER}" \ + -Pgithub.token="${token}" \ + ' + +RUN mkdir -p /out && \ + test "$(find /src/velocity/build/libs -maxdepth 1 -type f -name '*.jar' | wc -l)" -eq 1 && \ + cp "$(find /src/velocity/build/libs -maxdepth 1 -type f -name '*.jar' -print -quit)" /out/plugin.jar + +FROM alpine:3 +RUN mkdir -p /jar +COPY --from=build /out/plugin.jar /jar/plugin.jar +# No ENTRYPOINT or CMD: plugin-velocity-jar copies the JAR from this image. diff --git a/build.gradle.kts b/build.gradle.kts index 85fc847..d89e99b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1 +1,34 @@ plugins { id("gg.grounds.base-conventions") version "0.5.1" } + +group = "gg.grounds" + +val semanticVersion = + Regex( + "(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)(?:-(?:0|[1-9]\\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9]\\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?" + ) +val versionFileContents = file("version.txt").readText() +val versionFromFile = versionFileContents.removeSuffix("\n") + +check(versionFileContents == versionFromFile || versionFileContents == "$versionFromFile\n") { + "version.txt may contain only one optional trailing newline" +} + +check(semanticVersion.matches(versionFromFile)) { + "version.txt must contain a strict SemVer version" +} + +val resolvedVersion = providers.gradleProperty("versionOverride").orNull ?: versionFromFile + +check(semanticVersion.matches(resolvedVersion)) { + "versionOverride must contain a strict SemVer version" +} + +version = resolvedVersion + +gradle.projectsEvaluated { + subprojects { + // Convention plugins assign local-SNAPSHOT while they apply. Set the + // release-controlled root version only after every convention has run. + version = rootProject.version + } +} diff --git a/docs/superpowers/plans/2026-08-20-plugin-config-oci-stage-dependency.md b/docs/superpowers/plans/2026-08-20-plugin-config-oci-stage-dependency.md new file mode 100644 index 0000000..e2d0b0d --- /dev/null +++ b/docs/superpowers/plans/2026-08-20-plugin-config-oci-stage-dependency.md @@ -0,0 +1,124 @@ +# Plugin Config OCI Stage Dependency Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Publish the existing Velocity `plugin-config` runtime as a data-only OCI image, then pin it before `plugin-resourcepacks` in the platform bundle and Stage overlay. + +**Architecture:** `plugin-config` remains its own Velocity plugin; it is not shaded into `plugin-resourcepacks`. Release Please owns the semantic version and tag. The Docker image contains exactly the final shaded Velocity JAR at `/jar/plugin.jar`; the existing `plugin-velocity-jar` chart serves it. Bundle and Stage use fixed releases. Stage supplies `GROUNDS_ENVIRONMENT=stage` to satisfy the published resourcepacks plugin and keeps its edge-only bootstrap variable. + +**Tech Stack:** Kotlin/JVM 25, Gradle Shadow, GitHub Release Please, BuildKit, GHCR, Platform Bundle YAML, Helm values. + +**Spec:** `/home/lukas/grounds/.worktrees/bundle-resourcepacks-stage/docs/superpowers/specs/2026-08-20-resourcepacks-stage-integration-design.md` (amended by the verified runtime prerequisite below). + +## Global Constraints + +- `plugin-config` is a mandatory Velocity dependency of `plugin-resourcepacks`; the two plugins must be loaded in that order. +- No plugin is embedded into another plugin image or JAR. +- Release Please, never a manually created tag, publishes the new `plugin-config` OCI version. +- Docker build credentials are BuildKit secrets only and never remain in an image layer. +- Data-only final images contain `/jar/plugin.jar` and no entrypoint or command. +- Bundle and Stage use literal released SemVer pins, never moving tags or variables. +- Stage alone sets `RESOURCE_PACK_DEFAULT_CHANNEL=edge`; both its resourcepacks registration scope and the plugin's required deployment environment are `stage`. +- No Config Admin, R2, CDN credential, seed Job, or production overlay is introduced. + +## Task 1: Make `plugin-config` OCI-Releasable + +**Files:** +- Create: `Dockerfile` +- Modify: `.github/workflows/release.yml` +- Modify: `release-please-config.json` +- Create: `version.txt` +- Modify/Test: root Gradle version configuration and an artifact-contract test/task. + +- [ ] **Step 1: Write failing release/artifact contract tests** + +Assert the effective project version is strict SemVer from `version.txt` unless a strict `-PversionOverride` is supplied. Assert the final `velocity:shadowJar` output has a Velocity metadata file with that version and plugin id `plugin-config`. Assert the Dockerfile copies the root Gradle inputs plus `common/` and `velocity/`, uses a required BuildKit `github_token` secret, and places the single shaded JAR at `/jar/plugin.jar` without an entrypoint. + +- [ ] **Step 2: Run the focused RED test** + +Run the existing Gradle test/task that reads the release files. It must fail because no version source, Dockerfile, OCI release job, or artifact contract exists. + +- [ ] **Step 3: Implement the minimal release path** + +Adopt the proven `plugin-resourcepacks` data-image structure: JDK 25 build stage, `:velocity:shadowJar`, BuildKit secret for GitHub Packages resolution, then an Alpine final stage with exactly `/jar/plugin.jar`. Copy `common/` because `velocity` has a local project dependency on it. Keep Maven publication intact. Add a Docker reusable release job with only `contents: write` and `packages: write`; forward the existing packages-read secret exactly as the Maven job does. Make Release Please update `version.txt` and use the value as the Gradle version so tag/image/JAR metadata agree. + +- [ ] **Step 4: Run focused GREEN and full local verification** + +Run the release/artifact contract, `./gradlew --no-build-cache clean check`, build the data image locally where Docker is available, inspect `/jar/plugin.jar`, and verify no token appears in image history or configuration. + +- [ ] **Step 5: Commit** + +Commit a signed conventional `fix(release): publish plugin config image` change. Do not create a tag. + +## Task 2: Release and Capture the Config Plugin Version + +**Files:** Release-maintained version files only. + +- [ ] **Step 1: Open, validate, and merge the release-path PR** + +Push only the signed Task 1 branch and open one PR. Merge only after its CI is green and review findings are closed. + +- [ ] **Step 2: Let Release Please create the patch release** + +Merge the generated Release Please PR; do not hand-create a tag. Watch the tag-triggered Maven and OCI jobs. + +- [ ] **Step 3: Record the exact release** + +Capture `PLUGIN_CONFIG_VERSION` from the successful immutable OCI tag and inspect the JAR metadata inside it. Later tasks use this literal. + +## Task 3: Correct the Bundle Dependency Order + +**Files:** +- Modify: `/home/lukas/grounds/.worktrees/bundle-resourcepacks-0-1-2/bundle.yaml` +- Modify/Test: `/home/lukas/grounds/.worktrees/bundle-resourcepacks-0-1-2/scripts/validate-bundle.py` + +- [ ] **Step 1: Write failing bundle contract assertions** + +Require one literal-pinned `plugin-config` component using `plugin-velocity-jar`, its released image, and `PLUGIN_CONFIG_VERSION`. Require both `velocity` and `velocity-2` plugin lists to contain it exactly once before `plugin-resourcepacks`. Preserve literal resourcepacks `0.1.2` and prohibit all `RESOURCE_PACK_DEFAULT_CHANNEL` entries in the neutral bundle. + +- [ ] **Step 2: Run RED, then implement and run GREEN** + +Run `python3 scripts/validate-bundle.py`; it must fail before the component exists. Add the minimal component/resource envelope and ordered entries, then rerun the validator and `git diff --check`. + +- [ ] **Step 3: Commit** + +Commit a signed `fix(bundle): add config dependency for resourcepacks` change. + +## Task 4: Correct Stage Runtime Prerequisites + +**Files:** +- Create: `/home/lukas/grounds/.worktrees/deploy-resourcepacks-stage-0-1-2/environments/stage/components/plugin-config/component.yaml` +- Create: `/home/lukas/grounds/.worktrees/deploy-resourcepacks-stage-0-1-2/environments/stage/components/plugin-config/values.yaml` +- Modify/Test: `/home/lukas/grounds/.worktrees/deploy-resourcepacks-stage-0-1-2/environments/stage/components/velocity/values.yaml` and the Stage resourcepack topology test. + +- [ ] **Step 1: Write failing Stage assertions** + +Require the literal released config image pin; require `plugin-config` exactly once before `plugin-resourcepacks`; require exactly one `GROUNDS_ENVIRONMENT=stage` and exactly one `RESOURCE_PACK_DEFAULT_CHANNEL=edge`; reject missing, duplicate, moving, noncanonical, or production forms. Assert no Config Admin/R2/CDN variables are added. + +- [ ] **Step 2: Run RED, implement, and run GREEN** + +Run the focused Stage topology test before edits, then add the small plugin component and two Velocity variables/order changes. Run the test suite and CI Python component validation twice. + +- [ ] **Step 3: Commit** + +Commit a signed `fix(stage): load config before resourcepacks` change. + +## Task 5: Cross-Repository Acceptance + +- [ ] **Step 1: Independently review all three diffs** + +Verify immutable version agreement, plugin load order, final image JAR metadata, Stage-only environment, no credentials, and create-only Config Service bootstrap semantics. + +- [ ] **Step 2: Open PRs in dependency order** + +Merge `plugin-config` release first, then bundle and Stage PRs. Wait for each repository CI, including the authenticated Helm registry check. + +- [ ] **Step 3: Observe Stage after Argo sync** + +Verify Velocity loads both plugins; `network/stage/resourcepacks/global` is created only if absent with Edge source; a pre-existing override is unchanged; client reaches READY; a later Edge pointer update changes the resolved target without a deployment revision. + +## Self-Review + +- Spec coverage: Tasks 1–2 close the missing OCI artifact; Tasks 3–4 close the dependency/load-environment gaps; Task 5 preserves deployment-order and create-only acceptance. +- Placeholder scan: no task depends on an unspecified image, version, credential, or tag creation path. +- Type/version consistency: task 2 produces literal `PLUGIN_CONFIG_VERSION`; tasks 3 and 4 consume the same literal and retain resourcepacks `0.1.2`. diff --git a/release-please-config.json b/release-please-config.json index 9900f1b..69d799a 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -1,7 +1,12 @@ { - "release-type": "go", - "include-component-in-tag": false, "packages": { - ".": {} + ".": { + "release-type": "simple", + "package-name": "plugin-config", + "initial-version": "1.0.0", + "include-v-in-tag": true, + "include-component-in-tag": false, + "extra-files": ["version.txt"] + } } } diff --git a/velocity/build.gradle.kts b/velocity/build.gradle.kts index c5a6c56..1c5977e 100644 --- a/velocity/build.gradle.kts +++ b/velocity/build.gradle.kts @@ -1,3 +1,19 @@ +import com.github.gmazzo.buildconfig.BuildConfigExtension +import org.gradle.api.tasks.testing.Test + plugins { id("gg.grounds.velocity-conventions") } -dependencies { implementation(project(":common")) } +configure { + buildConfigFields.remove(buildConfigFields.getByName("VERSION")) + buildConfigField("String", "VERSION", "\"${rootProject.version}\"") +} + +dependencies { + implementation(project(":common")) + testImplementation(kotlin("test")) +} + +tasks.named("test") { + dependsOn("shadowJar") + systemProperty("releaseVersion", rootProject.version.toString()) +} diff --git a/velocity/src/test/kotlin/gg/grounds/config/ReleaseArtifactContractTest.kt b/velocity/src/test/kotlin/gg/grounds/config/ReleaseArtifactContractTest.kt new file mode 100644 index 0000000..795f73d --- /dev/null +++ b/velocity/src/test/kotlin/gg/grounds/config/ReleaseArtifactContractTest.kt @@ -0,0 +1,96 @@ +package gg.grounds.config + +import java.nio.file.Path +import java.util.zip.ZipFile +import kotlin.io.path.exists +import kotlin.io.path.readText +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ReleaseArtifactContractTest { + private val root = + generateSequence(Path.of(System.getProperty("user.dir"))) { it.parent } + .first { it.resolve("settings.gradle.kts").exists() } + + @Test + fun `release version is strict SemVer and controls Gradle project version`() { + val version = strictVersion(root.resolve("version.txt").readText()) + + assertEquals(version, System.getProperty("releaseVersion")) + } + + @Test + fun `shaded Velocity artifact carries the release version and plugin identity`() { + val version = strictVersion(root.resolve("version.txt").readText()) + val archive = root.resolve("velocity/build/libs/plugin-config-velocity.jar") + assertTrue(archive.exists(), "shadowJar did not produce $archive") + ZipFile(archive.toFile()).use { jar -> + val descriptor = jar.getEntry("velocity-plugin.json") + assertTrue(descriptor != null, "shaded artifact must contain velocity-plugin.json") + val metadata = jar.getInputStream(descriptor).bufferedReader().readText() + assertContains(metadata, "\"id\":\"plugin-config\"") + assertContains(metadata, "\"version\":\"$version\"") + } + } + + @Test + fun `Docker release path is a secret-backed data-only Velocity artifact`() { + val dockerfile = root.resolve("Dockerfile").readText() + val copySources = + dockerfile + .lineSequence() + .filter { it.startsWith("COPY ") && !it.startsWith("COPY --from=") } + .toList() + + assertEquals( + listOf( + "COPY gradle/ gradle/", + "COPY gradlew settings.gradle.kts build.gradle.kts version.txt ./", + "COPY common/ common/", + "COPY velocity/ velocity/", + ), + copySources, + ) + assertContains(dockerfile, "FROM eclipse-temurin:25-jdk AS build") + assertContains(dockerfile, "mkdir -p paper example-paper example-velocity") + assertContains(dockerfile, "--mount=type=secret,id=github_token,required=true") + assertContains(dockerfile, ":velocity:shadowJar") + assertContains(dockerfile, "COPY --from=build /out/plugin.jar /jar/plugin.jar") + assertFalse(Regex("(?m)^(ENTRYPOINT|CMD)\\b").containsMatchIn(dockerfile)) + } + + @Test + fun `release publishes Maven and OCI artifacts from the Release Please version`() { + val release = root.resolve(".github/workflows/release.yml").readText() + val releasePlease = root.resolve("release-please-config.json").readText() + + assertContains(release, "tags: [v*]") + assertContains(release, "maven:") + assertContains(release, "docker:") + assertContains(release, "groundsgg/.github/.github/workflows/gradle-publish.yml@main") + assertContains( + release, + "groundsgg/.github/.github/workflows/docker-gradle-build-push.yml@main", + ) + assertContains(release, "PACKAGES_TOKEN: ${'$'}{{ secrets.GROUNDS_PACKAGES_TOKEN }}") + assertContains(release, "contents: write") + assertContains(release, "packages: write") + assertContains(releasePlease, "\"extra-files\": [\"version.txt\"]") + assertContains(releasePlease, "\"include-v-in-tag\": true") + } + + private fun strictVersion(contents: String): String { + val version = contents.removeSuffix("\n") + assertTrue(contents == version || contents == "$version\n") + assertTrue( + Regex( + "(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)(?:-(?:0|[1-9]\\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9]\\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?" + ) + .matches(version) + ) + return version + } +} diff --git a/version.txt b/version.txt new file mode 100644 index 0000000..3eefcb9 --- /dev/null +++ b/version.txt @@ -0,0 +1 @@ +1.0.0