-
Notifications
You must be signed in to change notification settings - Fork 1
fix(release): publish plugin config image #15
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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`. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"] | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<BuildConfigExtension> { | ||
| buildConfigFields.remove(buildConfigFields.getByName("VERSION")) | ||
| buildConfigField("String", "VERSION", "\"${rootProject.version}\"") | ||
| } | ||
|
|
||
| dependencies { | ||
| implementation(project(":common")) | ||
| testImplementation(kotlin("test")) | ||
| } | ||
|
|
||
| tasks.named<Test>("test") { | ||
| dependsOn("shadowJar") | ||
| systemProperty("releaseVersion", rootProject.version.toString()) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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")) | ||
|
Comment on lines
+20
to
+22
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
| @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 | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| 1.0.0 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The final stage inherits Alpine's default
CMD ["/bin/sh"]; omitting a newCMDinstruction does not clear the base image configuration. Consequently, the published image does have a command despite the data-only image contract and the comment below, while the contract test only scans this Dockerfile for explicit instructions and cannot detect the inherited command. Use a commandless base such asscratchor explicitly clear the inherited command.Useful? React with 👍 / 👎.