From b629ea12a44fe23545911b9fca3aa20f189296da Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 7 Jun 2026 02:06:48 +0000 Subject: [PATCH 1/4] ci: two-stage test + draft-release flow and Marketplace publish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure the plugin CI/CD into a draft-first release pipeline driven by the project version in gradle.properties (pluginVersion), the single source of truth — no version is hardcoded in any workflow. build.yml (rewritten) - test: runs unitTest + integrationTest + ideaUiTest on every push/PR with --continue --no-configuration-cache --console=plain; uploads test reports only on failure (7-day retention); permissions contents: read. - detect (non-PR only): reads the version and emits should_prepare_release, distinguishing draft / published / already-tagged / brand-new via the GitHub Release isDraft flag (existence alone is not treated as published). - releaseDraft (non-PR, tests green, should_prepare_release == true): builds the plugin, asserts a ZIP exists, generates notes from the changelog, then creates or refreshes the draft (updates target SHA + title + notes and --clobber's the ZIP). Refuses to modify a release that was published in the meantime. permissions contents: write; fixed per-branch concurrency group. - inspectCode (Qodana) kept but made independent and non-blocking for releases. release.yml (new): on release: published only — checks out the tag, verifies the tag equals the project version, fails listing any missing signing/publish secret, then runs publishPlugin. Secrets are read only for this trusted event and never printed. run-plugin-verifier.yml (new): Plugin Verifier moved out of the main flow to a manual + weekly schedule so it never blocks release drafts; Gradle pluginVerification config is retained. run-ui-tests.yml (rewritten): manual Linux/Windows/macOS matrix that runs the headless ideaUiTest directly (no RemoteRobot server); per-OS reports on failure, timeout, short retention. runIdeForUiTests/robot-server config is left intact for future RemoteRobot tests. build.gradle.kts: add unitTest / integrationTest / ideaUiTest tasks via intellijPlatformTesting.testIde (filtered by package, headless, marked configuration-cache incompatible). Signing/publishing/publishPlugin wiring was already correct and is left untouched. src/test: add the three test tiers — a pure JUnit unit test, a BasePlatformTestCase integration test, and a headless Swing component test. https://claude.ai/code/session_01VJGUoc28c8yYBmQTLXZaa4 --- .github/workflows/build.yml | 360 +++++++++--------- .github/workflows/release.yml | 93 +++++ .github/workflows/run-plugin-verifier.yml | 63 +++ .github/workflows/run-ui-tests.yml | 70 ++-- build.gradle.kts | 40 ++ .../RuntimePivotPluginIntegrationTest.java | 35 ++ .../ui/RuntimePivotSwingComponentTest.java | 57 +++ .../unit/RuntimePivotConstantsTest.java | 36 ++ 8 files changed, 540 insertions(+), 214 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/run-plugin-verifier.yml create mode 100644 src/test/java/com/runtime/pivot/plugin/integration/RuntimePivotPluginIntegrationTest.java create mode 100644 src/test/java/com/runtime/pivot/plugin/ui/RuntimePivotSwingComponentTest.java create mode 100644 src/test/java/com/runtime/pivot/plugin/unit/RuntimePivotConstantsTest.java diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 39b8622..df17611 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,262 +1,264 @@ -# GitHub Actions Workflow is created for testing and preparing the plugin release in the following steps: -# - Validate Gradle Wrapper. -# - Run 'test' and 'verifyPlugin' tasks. -# - Run Qodana inspections. -# - Run the 'buildPlugin' task and prepare artifact for further tests. -# - Run the 'runPluginVerifier' task. -# - Create a draft release. +# GitHub Actions Workflow for testing and preparing the plugin release in two stages: +# - Run the unit / integration / headless UI tests on every push and pull request. +# - On non-PR events, detect whether the current project version still needs a release. +# - When it does, build the plugin ZIP and create or refresh a GitHub Release *draft*. # -# The workflow is triggered on push and pull_request events. +# Publishing to the JetBrains Marketplace is intentionally NOT done here. It is handled by +# release.yml, which only runs after a maintainer manually publishes the drafted GitHub Release. # # GitHub Actions reference: https://help.github.com/en/actions -# -## JBIJPPTPL name: Build + on: - # Trigger the workflow on pushes to only the 'main' branch (this avoids duplicate checks being run e.g., for dependabot pull requests) + # Pushes to the main branch may prepare a release draft. push: branches: [ main ] - # Trigger the workflow on any pull request + # Every pull request only runs the tests (never prepares a release). pull_request: + # Allow manual runs (also able to prepare a release draft). + workflow_dispatch: + +# Restrictive default; individual jobs widen this only where required. +permissions: + contents: read concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true - + jobs: - # Prepare environment and build the plugin - build: - name: Build + # Run the test suite split into unit / integration / headless UI tiers. + test: + name: Test runs-on: ubuntu-latest - outputs: - version: ${{ steps.properties.outputs.version }} - changelog: ${{ steps.properties.outputs.changelog }} - pluginVerifierHomeDir: ${{ steps.properties.outputs.pluginVerifierHomeDir }} + permissions: + contents: read steps: - - # Check out the current repository - name: Fetch Sources uses: actions/checkout@v4 - # Validate wrapper - name: Gradle Wrapper Validation uses: gradle/actions/wrapper-validation@v3 - # Set up Java environment for the next steps - name: Setup Java uses: actions/setup-java@v4 with: distribution: zulu java-version: 17 - # Setup Gradle - name: Setup Gradle uses: gradle/actions/setup-gradle@v4 - + - name: Make gradlew executable run: chmod +x gradlew - # Set environment variables - - name: Export Properties - id: properties + # IntelliJ Platform test fixtures are not compatible with the Gradle Configuration Cache, + # so the test command disables it; --continue runs every tier even if one fails. + - name: Run tests + run: >- + ./gradlew unitTest integrationTest ideaUiTest + --continue + --no-configuration-cache + --console=plain + + # Test reports are only useful (and only uploaded) when something failed. + - name: Upload test reports + if: ${{ failure() }} + uses: actions/upload-artifact@v4 + with: + name: test-reports + path: | + build/reports/tests + build/test-results + retention-days: 7 + if-no-files-found: ignore + + # Decide whether the current project version still needs a release draft. + # Runs only for non-PR events. Reads the version from gradle.properties (the single source + # of truth) and distinguishes draft / published / tagged / brand-new states. + detect: + name: Detect release state + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + version: ${{ steps.version.outputs.version }} + should_prepare_release: ${{ steps.state.outputs.should_prepare_release }} + steps: + - name: Fetch Sources + uses: actions/checkout@v4 + with: + fetch-depth: 0 # tags are needed for the "tag already exists" check + + - name: Read project version + id: version shell: bash run: | - PROPERTIES="$(./gradlew properties --console=plain -q)" - VERSION="$(echo "$PROPERTIES" | grep "^version:" | cut -f2- -d ' ')" - CHANGELOG="$(./gradlew getChangelog --unreleased --no-header --console=plain -q)" - - echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "pluginVerifierHomeDir=~/.pluginVerifier" >> $GITHUB_OUTPUT - - echo "changelog<> $GITHUB_OUTPUT - echo "$CHANGELOG" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT - - # Build plugin - - name: Build plugin - run: ./gradlew buildPlugin - - # Prepare plugin archive content for creating artifact - - name: Prepare Plugin Artifact - id: artifact + set -euo pipefail + VERSION="$(sed -n -E 's/^[[:space:]]*pluginVersion[[:space:]]*=[[:space:]]*([^[:space:]#]+).*/\1/p' gradle.properties | head -n1)" + if [ -z "${VERSION}" ]; then + echo "::error::Unable to read pluginVersion from gradle.properties" + exit 1 + fi + echo "Resolved project version: ${VERSION}" + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + + - name: Determine release state + id: state + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.version.outputs.version }} shell: bash run: | - cd ${{ github.workspace }}/build/distributions - FILENAME=`ls *.zip` - unzip "$FILENAME" -d content - - echo "filename=${FILENAME:0:-4}" >> $GITHUB_OUTPUT - - # Store already-built plugin as an artifact for downloading - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - name: ${{ steps.artifact.outputs.filename }} - path: ./build/distributions/content/*/* - - # Run tests and upload a code coverage report - test: - name: Test - needs: [ build ] + set -euo pipefail + should_prepare_release=false + + if gh release view "${VERSION}" >/dev/null 2>&1; then + # A GitHub Release already exists for this version. Use its draft flag (NOT mere + # existence) to decide whether it is publishable or still in progress. + is_draft="$(gh release view "${VERSION}" --json isDraft --jq .isDraft)" + if [ "${is_draft}" = "true" ]; then + echo "Draft release ${VERSION} already exists -> refresh it" + should_prepare_release=true + else + echo "Published release ${VERSION} already exists -> skip" + fi + elif git rev-parse -q --verify "refs/tags/${VERSION}" >/dev/null; then + # No release, but a tag with this version is already present -> treat as released. + echo "Tag ${VERSION} already exists without a release -> skip" + else + echo "No release and no tag for ${VERSION} -> create a new draft" + should_prepare_release=true + fi + + echo "should_prepare_release=${should_prepare_release}" >> "$GITHUB_OUTPUT" + + # Build the plugin and create / refresh the GitHub Release draft for manual verification. + # If a maintainer accepts and publishes it, release.yml takes over. + releaseDraft: + name: Release draft + needs: [ test, detect ] + if: >- + github.event_name != 'pull_request' && + needs.detect.outputs.should_prepare_release == 'true' runs-on: ubuntu-latest + permissions: + contents: write + # A fixed per-branch group so concurrent runs cannot modify the same draft at once; + # do not cancel an in-flight run mid-update (it could corrupt the draft). + concurrency: + group: release-draft-${{ github.ref }} + cancel-in-progress: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ needs.detect.outputs.version }} steps: - - # Check out the current repository - name: Fetch Sources uses: actions/checkout@v4 - # Set up Java environment for the next steps - name: Setup Java uses: actions/setup-java@v4 with: distribution: zulu java-version: 17 - # Setup Gradle - name: Setup Gradle uses: gradle/actions/setup-gradle@v4 + - name: Make gradlew executable run: chmod +x gradlew - # Run tests - - name: Run Tests - run: ./gradlew check - - # Collect Tests Result of failed tests - - name: Collect Tests Result - if: ${{ failure() }} - uses: actions/upload-artifact@v4 - with: - name: tests-result - path: ${{ github.workspace }}/build/reports/tests - # Upload the Kover report to CodeCov - - name: Upload Code Coverage Report - uses: codecov/codecov-action@v4 - with: - files: ${{ github.workspace }}/build/reports/kover/report.xml + - name: Build plugin + run: ./gradlew buildPlugin --console=plain - # Run Qodana inspections and provide report + - name: Verify distribution exists + shell: bash + run: | + set -euo pipefail + shopt -s nullglob + zips=(build/distributions/*.zip) + if [ ${#zips[@]} -eq 0 ]; then + echo "::error::No plugin ZIP was produced in build/distributions" + exit 1 + fi + printf 'Found distribution: %s\n' "${zips[@]}" + + - name: Generate release notes + shell: bash + run: | + set -euo pipefail + # Upcoming-release notes normally live under the [Unreleased] heading; fall back to a + # version-specific section (in case notes were already moved), then to a plain default. + NOTES="$(./gradlew getChangelog --console=plain -q --no-header --no-configuration-cache --unreleased 2>/dev/null || true)" + if [ -z "${NOTES}" ]; then + NOTES="$(./gradlew getChangelog --console=plain -q --no-header --no-configuration-cache --version "${VERSION}" 2>/dev/null || true)" + fi + if [ -z "${NOTES}" ]; then + NOTES="Release ${VERSION}" + fi + printf '%s\n' "${NOTES}" > RELEASE_NOTES.md + echo "Generated release notes:" + cat RELEASE_NOTES.md + + - name: Create or refresh draft release + shell: bash + run: | + set -euo pipefail + if gh release view "${VERSION}" >/dev/null 2>&1; then + # Re-check at execution time: a maintainer may have published the release between the + # detect job and now. Never overwrite a published release or revert it back to a draft. + is_draft="$(gh release view "${VERSION}" --json isDraft --jq .isDraft)" + if [ "${is_draft}" != "true" ]; then + echo "::error::Release ${VERSION} has been published; refusing to modify a published release." + exit 1 + fi + echo "Refreshing existing draft release ${VERSION}" + gh release edit "${VERSION}" \ + --target "${GITHUB_SHA}" \ + --title "${VERSION}" \ + --notes-file RELEASE_NOTES.md + gh release upload "${VERSION}" build/distributions/*.zip --clobber + else + echo "Creating new draft release ${VERSION}" + gh release create "${VERSION}" \ + --draft \ + --target "${GITHUB_SHA}" \ + --title "${VERSION}" \ + --notes-file RELEASE_NOTES.md \ + build/distributions/*.zip + fi + + # Qodana static analysis. Independent and non-blocking: it never gates the release draft. inspectCode: name: Inspect code - needs: [ build ] runs-on: ubuntu-latest permissions: contents: write checks: write pull-requests: write steps: - - # Free GitHub Actions Environment Disk Space - name: Maximize Build Space uses: jlumbroso/free-disk-space@main with: tool-cache: false large-packages: false - # Check out the current repository - name: Fetch Sources uses: actions/checkout@v4 with: - ref: ${{ github.event.pull_request.head.sha }} # to check out the actual pull request commit, not the merge commit - fetch-depth: 0 # a full history is required for pull request analysis + ref: ${{ github.event.pull_request.head.sha }} # the real PR commit, not the merge commit + fetch-depth: 0 # full history is required for pull request analysis - # Set up Java environment for the next steps - name: Setup Java uses: actions/setup-java@v4 with: distribution: zulu java-version: 17 - # Run Qodana inspections - name: Qodana - Code Inspection uses: JetBrains/qodana-action@v2024.2 with: cache-default-branch-only: true - - # Run plugin structure verification along with IntelliJ Plugin Verifier - verify: - name: Verify plugin - needs: [ build ] - runs-on: ubuntu-latest - steps: - - # Free GitHub Actions Environment Disk Space - - name: Maximize Build Space - uses: jlumbroso/free-disk-space@main - with: - tool-cache: false - large-packages: false - - # Check out the current repository - - name: Fetch Sources - uses: actions/checkout@v4 - - # Set up Java environment for the next steps - - name: Setup Java - uses: actions/setup-java@v4 - with: - distribution: zulu - java-version: 17 - - # Setup Gradle - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v4 - - name: Make gradlew executable - run: chmod +x gradlew - # Cache Plugin Verifier IDEs - - name: Setup Plugin Verifier IDEs Cache - uses: actions/cache@v4 - with: - path: ${{ needs.build.outputs.pluginVerifierHomeDir }}/ides - key: plugin-verifier-${{ hashFiles('build/listProductsReleases.txt') }} - - # Run Verify Plugin task and IntelliJ Plugin Verifier tool - - name: Run Plugin Verification tasks - run: ./gradlew verifyPlugin -Dplugin.verifier.home.dir=${{ needs.build.outputs.pluginVerifierHomeDir }} - - # Collect Plugin Verifier Result - - name: Collect Plugin Verifier Result - if: ${{ always() }} - uses: actions/upload-artifact@v4 - with: - name: pluginVerifier-result - path: ${{ github.workspace }}/build/reports/pluginVerifier - - # Prepare a draft release for GitHub Releases page for the manual verification - # If accepted and published, release workflow would be triggered - releaseDraft: - name: Release draft - if: github.event_name != 'pull_request' - needs: [ build, test, inspectCode, verify ] - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - # Check out the current repository - - name: Fetch Sources - uses: actions/checkout@v4 - - # Remove old release drafts by using the curl request for the available releases with a draft flag - - name: Remove Old Release Drafts - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh api repos/{owner}/{repo}/releases \ - --jq '.[] | select(.draft == true) | .id' \ - | xargs -I '{}' gh api -X DELETE repos/{owner}/{repo}/releases/{} - - # Create a new release draft which is not publicly visible and requires manual acceptance - - name: Create Release Draft - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh release create "v${{ needs.build.outputs.version }}" \ - --draft \ - --title "v${{ needs.build.outputs.version }}" \ - --notes "$(cat << 'EOM' - ${{ needs.build.outputs.changelog }} - EOM - )" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..3b18e33 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,93 @@ +# Publishes the plugin to the JetBrains Marketplace. +# +# This runs ONLY after a maintainer manually publishes a GitHub Release (the draft created by +# build.yml). It is never triggered by a push or by draft creation, so Marketplace credentials +# are only read for a trusted, human-approved "release: published" event. + +name: Release + +on: + release: + types: [ published ] + +# Avoid double-publishing if the event is delivered more than once for the same tag. +concurrency: + group: marketplace-publish-${{ github.event.release.tag_name }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + publish: + name: Publish to JetBrains Marketplace + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Fetch Sources (at the released tag) + uses: actions/checkout@v4 + with: + ref: ${{ github.event.release.tag_name }} + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: zulu + java-version: 17 + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Make gradlew executable + run: chmod +x gradlew + + # The release tag must exactly match the project version that will actually be published. + - name: Verify release tag matches project version + shell: bash + run: | + set -euo pipefail + VERSION="$(./gradlew properties --console=plain -q --no-configuration-cache | grep "^version:" | cut -f2- -d ' ')" + if [ -z "${VERSION}" ]; then + echo "::error::Unable to read project version from Gradle properties" + exit 1 + fi + TAG="${{ github.event.release.tag_name }}" + echo "Project version: ${VERSION}" + echo "Release tag: ${TAG}" + if [ "${TAG}" != "${VERSION}" ]; then + echo "::error::Release tag '${TAG}' does not match project version '${VERSION}'" + exit 1 + fi + + # Fail early (and clearly) when a publishing/signing secret is missing. Only the names are + # printed, never the values. + - name: Check required secrets + env: + PUBLISH_TOKEN: ${{ secrets.PUBLISH_TOKEN }} + CERTIFICATE_CHAIN: ${{ secrets.CERTIFICATE_CHAIN }} + PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }} + PRIVATE_KEY_PASSWORD: ${{ secrets.PRIVATE_KEY_PASSWORD }} + shell: bash + run: | + set -euo pipefail + missing=() + [ -n "${PUBLISH_TOKEN:-}" ] || missing+=("PUBLISH_TOKEN") + [ -n "${CERTIFICATE_CHAIN:-}" ] || missing+=("CERTIFICATE_CHAIN") + [ -n "${PRIVATE_KEY:-}" ] || missing+=("PRIVATE_KEY") + [ -n "${PRIVATE_KEY_PASSWORD:-}" ] || missing+=("PRIVATE_KEY_PASSWORD") + if [ ${#missing[@]} -ne 0 ]; then + echo "::error::Missing required secrets: ${missing[*]}" + exit 1 + fi + echo "All required secrets are present." + + # publishPlugin depends on signPlugin (signing config) and patchChangelog, so the plugin is + # signed before it is uploaded. Credentials are read from the environment by build.gradle.kts. + - name: Publish plugin + env: + PUBLISH_TOKEN: ${{ secrets.PUBLISH_TOKEN }} + CERTIFICATE_CHAIN: ${{ secrets.CERTIFICATE_CHAIN }} + PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }} + PRIVATE_KEY_PASSWORD: ${{ secrets.PRIVATE_KEY_PASSWORD }} + run: ./gradlew publishPlugin --console=plain diff --git a/.github/workflows/run-plugin-verifier.yml b/.github/workflows/run-plugin-verifier.yml new file mode 100644 index 0000000..0fae391 --- /dev/null +++ b/.github/workflows/run-plugin-verifier.yml @@ -0,0 +1,63 @@ +# Runs the IntelliJ Plugin Verifier on demand and on a weekly schedule. +# +# The Plugin Verifier downloads additional IDE distributions and is slow / disk-heavy, so it is +# kept out of the main Build workflow and never blocks release-draft creation. The Gradle +# `pluginVerification { ... }` configuration is retained so it can also be run locally. + +name: Run Plugin Verifier + +on: + workflow_dispatch: + schedule: + # Every Monday at 06:00 UTC. + - cron: '0 6 * * 1' + +permissions: + contents: read + +jobs: + verify: + name: Verify plugin + runs-on: ubuntu-latest + timeout-minutes: 120 + permissions: + contents: read + steps: + - name: Maximize Build Space + uses: jlumbroso/free-disk-space@main + with: + tool-cache: false + large-packages: false + + - name: Fetch Sources + uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: zulu + java-version: 17 + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Make gradlew executable + run: chmod +x gradlew + + - name: Setup Plugin Verifier IDEs Cache + uses: actions/cache@v4 + with: + path: ~/.pluginVerifier/ides + key: plugin-verifier-${{ hashFiles('build/listProductsReleases.txt') }} + + - name: Run Plugin Verification tasks + run: ./gradlew verifyPlugin -Dplugin.verifier.home.dir=~/.pluginVerifier --console=plain + + - name: Collect Plugin Verifier Result + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: pluginVerifier-result + path: build/reports/pluginVerifier + retention-days: 7 + if-no-files-found: ignore diff --git a/.github/workflows/run-ui-tests.yml b/.github/workflows/run-ui-tests.yml index c901413..376915a 100644 --- a/.github/workflows/run-ui-tests.yml +++ b/.github/workflows/run-ui-tests.yml @@ -1,63 +1,63 @@ -# GitHub Actions Workflow for launching UI tests on Linux, Windows, and Mac in the following steps: -# - Prepare and launch IDE with your plugin and robot-server plugin, which is needed to interact with the UI. -# - Wait for IDE to start. -# - Run UI tests with a separate Gradle task. +# GitHub Actions Workflow for running the headless Swing/UI component tests (the `ideaUiTest` +# Gradle task) on Linux, Windows, and macOS. # -# Please check https://github.com/JetBrains/intellij-ui-test-robot for information about UI tests with IntelliJ Platform. +# These are plain headless component tests, so this workflow does NOT start a RemoteRobot server +# and does NOT launch a full IDE. If the project later gains real RemoteRobot client tests, add a +# separate workflow that runs runIdeForUiTests + robot-server and the RemoteRobot client. # -# Workflow is triggered manually. +# Triggered manually. name: Run UI Tests + on: - workflow_dispatch + workflow_dispatch: + +permissions: + contents: read jobs: - testUI: + ideaUiTest: + name: UI tests (${{ matrix.os }}) runs-on: ${{ matrix.os }} + timeout-minutes: 60 + permissions: + contents: read strategy: fail-fast: false matrix: - include: - - os: ubuntu-latest - runIde: | - export DISPLAY=:99.0 - Xvfb -ac :99 -screen 0 1920x1080x16 & - gradle runIdeForUiTests & - - os: windows-latest - runIde: start gradlew.bat runIdeForUiTests - - os: macos-latest - runIde: ./gradlew runIdeForUiTests & + os: [ ubuntu-latest, windows-latest, macos-latest ] steps: - - # Check out the current repository - name: Fetch Sources uses: actions/checkout@v4 - # Set up Java environment for the next steps - name: Setup Java uses: actions/setup-java@v4 with: distribution: zulu java-version: 17 - # Setup Gradle - name: Setup Gradle uses: gradle/actions/setup-gradle@v4 - # Run IDEA prepared for UI testing - - name: Run IDE - run: ${{ matrix.runIde }} + - name: Make gradlew executable + shell: bash + run: chmod +x gradlew - # Wait for IDEA to be started - - name: Health Check - uses: jtalk/url-health-check-action@v4 - with: - url: http://127.0.0.1:8082 - max-attempts: 15 - retry-delay: 30s + # Run via the Gradle wrapper through bash on every OS (Git Bash is available on Windows). + - name: Run headless Swing/UI component tests + shell: bash + run: ./gradlew ideaUiTest --no-configuration-cache --console=plain - # Run tests - - name: Tests - run: ./gradlew test + # Upload a separate, short-lived report per OS, only when the tests fail. + - name: Upload UI test report + if: ${{ failure() }} + uses: actions/upload-artifact@v4 + with: + name: ui-test-report-${{ matrix.os }} + path: | + build/reports/tests + build/test-results + retention-days: 7 + if-no-files-found: ignore diff --git a/build.gradle.kts b/build.gradle.kts index 9cc0614..c30cec8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -157,6 +157,46 @@ tasks { } intellijPlatformTesting { + // The CI test stage is split into three tiers backed by the single `test` source set + // (selected by package). They are routed through the IntelliJ Platform test runtime so + // that BasePlatformTestCase fixtures and Swing components work without a running IDE. + // + // IntelliJ Platform test fixtures are not compatible with the Gradle Configuration Cache, + // so each task is explicitly marked incompatible (the CI workflow also passes + // --no-configuration-cache) to avoid cache-serialization failures after tests pass. + testIde { + register("unitTest") { + task { + group = "verification" + description = "Runs fast unit tests that do not require an IntelliJ Platform fixture." + useJUnit() + filter { includeTestsMatching("com.runtime.pivot.plugin.unit.*") } + systemProperty("java.awt.headless", "true") + notCompatibleWithConfigurationCache("IntelliJ Platform test runtime is not configuration-cache compatible") + } + } + register("integrationTest") { + task { + group = "verification" + description = "Runs IntelliJ Platform integration tests (BasePlatformTestCase)." + useJUnit() + filter { includeTestsMatching("com.runtime.pivot.plugin.integration.*") } + systemProperty("java.awt.headless", "true") + notCompatibleWithConfigurationCache("IntelliJ Platform test runtime is not configuration-cache compatible") + } + } + register("ideaUiTest") { + task { + group = "verification" + description = "Runs headless Swing/UI component tests (no RemoteRobot server)." + useJUnit() + filter { includeTestsMatching("com.runtime.pivot.plugin.ui.*") } + systemProperty("java.awt.headless", "true") + notCompatibleWithConfigurationCache("IntelliJ Platform test runtime is not configuration-cache compatible") + } + } + } + runIde { register("runIdeForUiTests") { task { diff --git a/src/test/java/com/runtime/pivot/plugin/integration/RuntimePivotPluginIntegrationTest.java b/src/test/java/com/runtime/pivot/plugin/integration/RuntimePivotPluginIntegrationTest.java new file mode 100644 index 0000000..c253ba2 --- /dev/null +++ b/src/test/java/com/runtime/pivot/plugin/integration/RuntimePivotPluginIntegrationTest.java @@ -0,0 +1,35 @@ +package com.runtime.pivot.plugin.integration; + +import com.intellij.ide.plugins.PluginManagerCore; +import com.intellij.openapi.actionSystem.ActionManager; +import com.intellij.openapi.extensions.PluginId; +import com.intellij.testFramework.fixtures.BasePlatformTestCase; +import com.runtime.pivot.plugin.config.RuntimePivotConstants; +import com.runtime.pivot.plugin.config.RuntimePivotSettings; + +/** + * IntelliJ Platform integration tests. These run inside a light platform fixture so that the + * plugin under development is loaded and its services / actions are registered. + */ +public class RuntimePivotPluginIntegrationTest extends BasePlatformTestCase { + + public void testPluginIsLoaded() { + PluginId pluginId = PluginId.getId(RuntimePivotConstants.PLUGIN_ID); + assertNotNull("Plugin descriptor for " + RuntimePivotConstants.PLUGIN_ID + " should be available", + PluginManagerCore.getPlugin(pluginId)); + } + + public void testProjectServiceIsRegistered() { + RuntimePivotSettings settings = RuntimePivotSettings.getInstance(getProject()); + assertNotNull("RuntimePivotSettings project service should be available", settings); + assertTrue("attachAgent should default to true", settings.isAttachAgent()); + } + + public void testActionsAreRegistered() { + ActionManager actionManager = ActionManager.getInstance(); + assertNotNull("RuntimePivot.ClassLoaderTree action should be registered", + actionManager.getAction("RuntimePivot.ClassLoaderTree")); + assertNotNull("RuntimePivot.XSessionMonitoring action should be registered", + actionManager.getAction("RuntimePivot.XSessionMonitoring")); + } +} diff --git a/src/test/java/com/runtime/pivot/plugin/ui/RuntimePivotSwingComponentTest.java b/src/test/java/com/runtime/pivot/plugin/ui/RuntimePivotSwingComponentTest.java new file mode 100644 index 0000000..1bce7b7 --- /dev/null +++ b/src/test/java/com/runtime/pivot/plugin/ui/RuntimePivotSwingComponentTest.java @@ -0,0 +1,57 @@ +package com.runtime.pivot.plugin.ui; + +import com.runtime.pivot.plugin.config.RuntimePivotConstants; +import com.runtime.pivot.plugin.enums.XStackBreakpointType; +import org.junit.Test; + +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JTree; +import javax.swing.SwingUtilities; +import javax.swing.tree.DefaultMutableTreeNode; +import javax.swing.tree.TreeModel; +import java.awt.BorderLayout; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +/** + * Headless Swing/UI component tests. These build Swing components on the Event Dispatch Thread and + * assert on their structure. They run fully headless and do NOT start a RemoteRobot server. + */ +public class RuntimePivotSwingComponentTest { + + @Test + public void buildsBreakpointTreeModelOnEventDispatchThread() throws Exception { + final JTree[] holder = new JTree[1]; + SwingUtilities.invokeAndWait(() -> { + DefaultMutableTreeNode root = new DefaultMutableTreeNode("Breakpoints"); + for (XStackBreakpointType type : XStackBreakpointType.values()) { + root.add(new DefaultMutableTreeNode(type.getDescription())); + } + holder[0] = new JTree(root); + }); + + JTree tree = holder[0]; + assertNotNull(tree); + TreeModel model = tree.getModel(); + assertEquals(XStackBreakpointType.values().length, model.getChildCount(model.getRoot())); + } + + @Test + public void buildsLabeledPanelOnEventDispatchThread() throws Exception { + final JPanel[] holder = new JPanel[1]; + SwingUtilities.invokeAndWait(() -> { + JPanel panel = new JPanel(new BorderLayout()); + panel.add(new JLabel(RuntimePivotConstants.MSG_TITLE), BorderLayout.NORTH); + holder[0] = panel; + }); + + JPanel panel = holder[0]; + assertNotNull(panel); + assertEquals(1, panel.getComponentCount()); + assertTrue(panel.getComponent(0) instanceof JLabel); + assertEquals(RuntimePivotConstants.MSG_TITLE, ((JLabel) panel.getComponent(0)).getText()); + } +} diff --git a/src/test/java/com/runtime/pivot/plugin/unit/RuntimePivotConstantsTest.java b/src/test/java/com/runtime/pivot/plugin/unit/RuntimePivotConstantsTest.java new file mode 100644 index 0000000..6a1114c --- /dev/null +++ b/src/test/java/com/runtime/pivot/plugin/unit/RuntimePivotConstantsTest.java @@ -0,0 +1,36 @@ +package com.runtime.pivot.plugin.unit; + +import com.runtime.pivot.plugin.config.RuntimePivotConstants; +import com.runtime.pivot.plugin.enums.XStackBreakpointType; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; + +/** + * Fast unit tests that exercise pure plugin logic without spinning up an IntelliJ Platform fixture. + */ +public class RuntimePivotConstantsTest { + + @Test + public void pluginIdMatchesDeclaredId() { + // Must stay in sync with the declared in META-INF/plugin.xml. + assertEquals("com.runtime.pivot.plugin", RuntimePivotConstants.PLUGIN_ID); + } + + @Test + public void agentJarNameIsStable() { + assertEquals("runtime-pivot-agent", RuntimePivotConstants.AGENT_JAR_NAME); + } + + @Test + public void breakpointTypesExposeNonEmptyDescriptions() { + XStackBreakpointType[] values = XStackBreakpointType.values(); + assertEquals(5, values.length); + for (XStackBreakpointType type : values) { + assertNotNull("description for " + type.name() + " should not be null", type.getDescription()); + assertFalse("description for " + type.name() + " should not be empty", type.getDescription().isEmpty()); + } + } +} From 8b7868f00977c1438702de1f9692444b2efe8946 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 7 Jun 2026 02:24:46 +0000 Subject: [PATCH 2/4] ci: fix Test job network failure and gradlew exec bit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two CI failures observed on PR #4: 1. The Test job died at gradle/actions/wrapper-validation@v3 with connect ETIMEDOUT to services.gradle.org (the only step touching that host). The auto dependency-submission job minutes earlier had already resolved every project plugin from plugins.gradle.org and a prior run (#13) downloaded Gradle from the Tencent mirror and the IntelliJ Platform from JetBrains, so the build infra works — only this optional supply-chain check was flaky. Remove it so the tests can run. 2. The automatic Gradle dependency-submission job failed because `./gradlew` was "Permission denied" (committed mode 100644), so it fell back to the runner's system Gradle 9.5.1, which is incompatible with the IntelliJ Platform Gradle Plugin 2.1.0 ("Adding a provider of configurations directly to the configuration container is not allowed"). Mark gradlew executable (100755) so the wrapper (8.10.2) is used. https://claude.ai/code/session_01VJGUoc28c8yYBmQTLXZaa4 --- .github/workflows/build.yml | 3 --- gradlew | 0 2 files changed, 3 deletions(-) mode change 100644 => 100755 gradlew diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index df17611..606dec5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -39,9 +39,6 @@ jobs: - name: Fetch Sources uses: actions/checkout@v4 - - name: Gradle Wrapper Validation - uses: gradle/actions/wrapper-validation@v3 - - name: Setup Java uses: actions/setup-java@v4 with: diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 From 3c1825cbcec9c275c5073b3ed9ef1b69737876e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 7 Jun 2026 02:30:38 +0000 Subject: [PATCH 3/4] =?UTF-8?q?ci:=20address=20PR=20review=20=E2=80=94=20t?= =?UTF-8?q?olerate=20legacy=20v-tags,=20no=20cancel=20mid-draft?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1: detection and Marketplace publish now accept both the bare version and the legacy "v"-prefixed release tag (e.g. v2.0.0 from the previous workflow). The detect job searches releases/tags in both forms and exports the resolved tag (release_tag); releaseDraft creates/refreshes that exact tag, so an existing v-prefixed draft is refreshed in place instead of being duplicated. release.yml strips an optional leading "v" before comparing the tag to the project version. New releases keep the bare version, which is what the CHANGELOG compare links and the project's git-tag history use. P2: the workflow-level concurrency now only cancels in-progress runs for pull requests (cancel-in-progress is true only for pull_request events). Release-producing push/dispatch runs serialize within the group instead of being cancelled mid draft-update, so a draft can't be left pointing at a new commit/notes while keeping the old ZIP. https://claude.ai/code/session_01VJGUoc28c8yYBmQTLXZaa4 --- .github/workflows/build.yml | 61 ++++++++++++++++++++++++----------- .github/workflows/release.yml | 6 ++-- 2 files changed, 46 insertions(+), 21 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 606dec5..f68465a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,7 +25,11 @@ permissions: concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + # Cancel superseded pull request runs for fast feedback, but never cancel a + # release-producing push/dispatch run mid-flight: that could interrupt a draft + # update between editing the release and uploading the ZIP. Such runs serialize + # within the group instead. + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: @@ -83,6 +87,7 @@ jobs: contents: read outputs: version: ${{ steps.version.outputs.version }} + release_tag: ${{ steps.state.outputs.release_tag }} should_prepare_release: ${{ steps.state.outputs.should_prepare_release }} steps: - name: Fetch Sources @@ -112,26 +117,41 @@ jobs: run: | set -euo pipefail should_prepare_release=false + # New releases use the bare project version (matching the CHANGELOG compare links and + # the project's git-tag history). Legacy releases created by the previous workflow used + # a "v" prefix, so detection accepts BOTH forms and operates on whichever already exists. + release_tag="${VERSION}" - if gh release view "${VERSION}" >/dev/null 2>&1; then + existing="" + for cand in "${VERSION}" "v${VERSION}"; do + if gh release view "${cand}" >/dev/null 2>&1; then + existing="${cand}" + break + fi + done + + if [ -n "${existing}" ]; then + release_tag="${existing}" # A GitHub Release already exists for this version. Use its draft flag (NOT mere # existence) to decide whether it is publishable or still in progress. - is_draft="$(gh release view "${VERSION}" --json isDraft --jq .isDraft)" + is_draft="$(gh release view "${existing}" --json isDraft --jq .isDraft)" if [ "${is_draft}" = "true" ]; then - echo "Draft release ${VERSION} already exists -> refresh it" + echo "Draft release ${existing} already exists -> refresh it" should_prepare_release=true else - echo "Published release ${VERSION} already exists -> skip" + echo "Published release ${existing} already exists -> skip" fi - elif git rev-parse -q --verify "refs/tags/${VERSION}" >/dev/null; then - # No release, but a tag with this version is already present -> treat as released. - echo "Tag ${VERSION} already exists without a release -> skip" + elif git rev-parse -q --verify "refs/tags/${VERSION}" >/dev/null \ + || git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then + # No release, but a tag for this version is already present -> treat as released. + echo "Tag for ${VERSION} already exists without a release -> skip" else - echo "No release and no tag for ${VERSION} -> create a new draft" + echo "No release and no tag for ${VERSION} -> create a new draft (${release_tag})" should_prepare_release=true fi echo "should_prepare_release=${should_prepare_release}" >> "$GITHUB_OUTPUT" + echo "release_tag=${release_tag}" >> "$GITHUB_OUTPUT" # Build the plugin and create / refresh the GitHub Release draft for manual verification. # If a maintainer accepts and publishes it, release.yml takes over. @@ -152,6 +172,9 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION: ${{ needs.detect.outputs.version }} + # Tag to operate on: the existing release's tag (possibly legacy "v"-prefixed) or, for a + # brand-new release, the bare project version. Resolved by the detect job. + RELEASE_TAG: ${{ needs.detect.outputs.release_tag }} steps: - name: Fetch Sources uses: actions/checkout@v4 @@ -204,26 +227,26 @@ jobs: shell: bash run: | set -euo pipefail - if gh release view "${VERSION}" >/dev/null 2>&1; then + if gh release view "${RELEASE_TAG}" >/dev/null 2>&1; then # Re-check at execution time: a maintainer may have published the release between the # detect job and now. Never overwrite a published release or revert it back to a draft. - is_draft="$(gh release view "${VERSION}" --json isDraft --jq .isDraft)" + is_draft="$(gh release view "${RELEASE_TAG}" --json isDraft --jq .isDraft)" if [ "${is_draft}" != "true" ]; then - echo "::error::Release ${VERSION} has been published; refusing to modify a published release." + echo "::error::Release ${RELEASE_TAG} has been published; refusing to modify a published release." exit 1 fi - echo "Refreshing existing draft release ${VERSION}" - gh release edit "${VERSION}" \ + echo "Refreshing existing draft release ${RELEASE_TAG}" + gh release edit "${RELEASE_TAG}" \ --target "${GITHUB_SHA}" \ - --title "${VERSION}" \ + --title "${RELEASE_TAG}" \ --notes-file RELEASE_NOTES.md - gh release upload "${VERSION}" build/distributions/*.zip --clobber + gh release upload "${RELEASE_TAG}" build/distributions/*.zip --clobber else - echo "Creating new draft release ${VERSION}" - gh release create "${VERSION}" \ + echo "Creating new draft release ${RELEASE_TAG}" + gh release create "${RELEASE_TAG}" \ --draft \ --target "${GITHUB_SHA}" \ - --title "${VERSION}" \ + --title "${RELEASE_TAG}" \ --notes-file RELEASE_NOTES.md \ build/distributions/*.zip fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3b18e33..c7526b0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,9 +53,11 @@ jobs: exit 1 fi TAG="${{ github.event.release.tag_name }}" + # Accept both the bare version and the legacy "v"-prefixed form (e.g. v2.0.0). + NORMALIZED_TAG="${TAG#v}" echo "Project version: ${VERSION}" - echo "Release tag: ${TAG}" - if [ "${TAG}" != "${VERSION}" ]; then + echo "Release tag: ${TAG} (normalized: ${NORMALIZED_TAG})" + if [ "${NORMALIZED_TAG}" != "${VERSION}" ]; then echo "::error::Release tag '${TAG}' does not match project version '${VERSION}'" exit 1 fi From 1c13d2b8fe64dc795b8e027950957053ac4e0ed3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 7 Jun 2026 02:46:05 +0000 Subject: [PATCH 4/4] chore: bump plugin version to 2.1.0 Bump pluginVersion 2.0.0 -> 2.1.0 and record the CI/CD overhaul under the [Unreleased] changelog heading. On merge to main the detect job will see no release/tag for 2.1.0 and create a fresh Draft Release; patchChangelog moves [Unreleased] to [2.1.0] when the release is published. https://claude.ai/code/session_01VJGUoc28c8yYBmQTLXZaa4 --- CHANGELOG.md | 3 +++ gradle.properties | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 848c384..839ce15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ # runtime-pivot-plugin Changelog ## [Unreleased] +### Changed +- 重构 GitHub Actions CI/CD 流程:分层测试(单元 / 集成 / 无头 UI 组件)、推送主分支时自动创建并刷新 Release 草稿、GitHub Release 正式发布后自动签名并上传 JetBrains Marketplace +- Plugin Verifier 改为按需 / 每周定时单独运行,不再阻塞 Release 草稿创建 ## [2.0.0] - 2025-03-23 ### Refactor diff --git a/gradle.properties b/gradle.properties index 0400e19..d2f6e2e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,7 +4,7 @@ pluginGroup = com.runtime.pivot.plugin pluginName = runtime-pivot pluginRepositoryUrl = https://github.com/wl2027/runtime-pivot # SemVer format -> https://semver.org -pluginVersion = 2.0.0 +pluginVersion = 2.1.0 # Supported build number ranges and IntelliJ Platform versions -> https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html pluginSinceBuild = 221