diff --git a/.github/workflows/release-all-tools.yml b/.github/workflows/release-all-tools.yml new file mode 100644 index 000000000..1e9c70f54 --- /dev/null +++ b/.github/workflows/release-all-tools.yml @@ -0,0 +1,75 @@ +name: Combined release tools + +on: + pull_request: + paths: + - 'releaseAll.sh' + - 'scripts/release-all/**' + - 'matrix-bom/verifyBomApi.sh' + - '.github/workflows/release-all-tools.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + portable-tools: + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 40 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + - uses: gradle/actions/setup-gradle@v4 + - name: Install shell tools + shell: bash + run: | + if [[ "$RUNNER_OS" == macOS ]]; then + brew install bash shellcheck + else + sudo apt-get update + sudo apt-get install -y shellcheck + fi + - name: Prepare Groovy CLI + shell: bash + run: | + mkdir -p "$RUNNER_TEMP/release-cli/bin" + cat > "$RUNNER_TEMP/release-cli/pom.xml" <<'XML' + 4.0.0 + release.toolscli1 + + org.apache.groovygroovy5.1.1 + org.apache.groovygroovy-json5.1.1 + org.apache.groovygroovy-xml5.1.1 + + XML + mvn -B -f "$RUNNER_TEMP/release-cli/pom.xml" org.apache.maven.plugins:maven-dependency-plugin:3.11.0:copy-dependencies -DoutputDirectory="$RUNNER_TEMP/release-cli/lib" + cat > "$RUNNER_TEMP/release-cli/bin/groovy" <<'SH' + #!/usr/bin/env bash + exec java -cp "$RUNNER_TEMP/release-cli/lib/*" groovy.ui.GroovyMain "$@" + SH + chmod +x "$RUNNER_TEMP/release-cli/bin/groovy" + echo "$RUNNER_TEMP/release-cli/bin" >> "$GITHUB_PATH" + - name: Verify portable helpers and workflow + shell: bash + run: | + bash -n releaseAll.sh matrix-bom/verifyBomApi.sh scripts/release-all/preflight.sh + shellcheck releaseAll.sh matrix-bom/verifyBomApi.sh scripts/release-all/preflight.sh + groovy scripts/release-all/test-tools.groovy . + groovy scripts/release-all/test-inventory-inheritance.groovy . + groovy scripts/release-all/test-workflow.groovy . + - name: Verify BOM workflows on Linux + if: runner.os == 'Linux' + shell: bash + env: + RUN_EXTERNAL_TESTS: 'false' + RUN_SLOW_TESTS: 'false' + GRADLE_OPTS: '-Dorg.gradle.workers.max=2' + run: | + BOM_VERIFY_REPO="$GITHUB_WORKSPACE/build/releaseAll/bom-verify" bash matrix-bom/verifyBomApi.sh + groovy scripts/release-all/test-manifest-verifier.groovy . build/releaseAll/bom-verify diff --git a/build.gradle b/build.gradle index a89dccb1b..1580c5cb5 100644 --- a/build.gradle +++ b/build.gradle @@ -2,6 +2,12 @@ plugins { alias(libs.plugins.benManesVersions) apply false alias(libs.plugins.spotless) apply false alias(libs.plugins.alipsaMavenModule) + alias(libs.plugins.alipsaNexusRelease) apply false +} + +if (providers.gradleProperty('releaseAllMode').isPresent()) { + apply plugin: 'se.alipsa.nexus-release-plugin' + nexusReleasePlugin.combinedRelease { } } description = "Groovy libraries for working with matrix ([][] data)" @@ -392,39 +398,61 @@ tasks.register('dependencyUpdates') { finalizedBy tasks.named('dependencyUpdatesSummary') } -tasks.register('latestMavenVersions') { - description = 'Lists the latest Maven Central version for each published module' - group = 'Help' - // Resolve all data at configuration time to satisfy configuration cache - def modules = [] - subprojects.findAll { it.plugins.hasPlugin('maven-publish') }.each { - modules << [name: it.name, group: it.group.toString(), artifact: it.name] - } - def bomFile = file('matrix-bom/bom.xml') - if (bomFile.exists()) { - def bom = new groovy.xml.XmlSlurper().parse(bomFile) - modules << [name: 'matrix-bom', group: bom.groupId.text(), artifact: bom.artifactId.text()] +// Each published subproject gets its own latestMavenVersions task from nexus-release-plugin. +// This task only covers the Maven-built matrix-bom and matrix-all, which Gradle does not model. +def latestMavenBomVersions = tasks.register('latestMavenBomVersions') { + description = 'Lists the latest Maven Central versions of matrix-bom and matrix-all' + group = 'help' + // Resolve coordinates at configuration time to satisfy the configuration cache. + List> modules = ['matrix-bom/bom.xml', 'matrix-bom/pom.xml'].collect { String path -> + def pom = new groovy.xml.XmlSlurper().parse(file(path)) + [group: pom.groupId.text(), artifact: pom.artifactId.text()] } - modules.sort { it.name } doLast { - def repoBase = 'https://repo1.maven.org/maven2' - modules.each { mod -> + modules.each { Map mod -> try { - def groupPath = mod.group.replace('.', '/') - def metadataUrl = URI.create("${repoBase}/${groupPath}/${mod.artifact}/maven-metadata.xml").toURL() - def metadata = new groovy.xml.XmlSlurper().parseText(metadataUrl.text) + def metadataUrl = URI.create("https://repo1.maven.org/maven2/${mod.group.replace('.', '/')}/${mod.artifact}/maven-metadata.xml").toURL() + def connection = metadataUrl.openConnection() + connection.connectTimeout = 5000 + connection.readTimeout = 5000 + def metadata = connection.inputStream.withCloseable { new groovy.xml.XmlSlurper().parse(it) } def latest = metadata.versioning.release.text() ?: metadata.versioning.latest.text() if (latest) { - println "${mod.name}: ${mod.group}:${mod.artifact}:${latest}" + println "${mod.artifact}: ${mod.group}:${mod.artifact}:${latest}" } else { - println "${mod.name}: no release version found" + println "${mod.artifact}: no release version found" } - } catch (FileNotFoundException e) { - println "${mod.name}: not found on Maven Central" + } catch (FileNotFoundException ignored) { + println "${mod.artifact}: not found on Maven Central" } catch (Exception e) { - println "${mod.name}: error querying Maven Central (${e.message})" + println "${mod.artifact}: error querying Maven Central (${e.message})" } } } } + +if (providers.gradleProperty('releaseAllMode').isPresent()) { + // The plugin's root task would list every subproject again; keep only the Maven-built modules. + tasks.named('latestMavenVersions') { + enabled = false + dependsOn latestMavenBomVersions + } +} else { + tasks.register('latestMavenVersions') { + description = 'Lists the latest Maven Central version of matrix-bom and matrix-all; subprojects list their own' + group = 'help' + dependsOn latestMavenBomVersions + } +} + +if (providers.gradleProperty('releaseAllMode').isPresent()) { + subprojects { + tasks.withType(Test).configureEach { + useJUnitPlatform { excludeTags 'flaky' } + } + } + gradle.projectsEvaluated { + apply from: 'gradle/release-all.gradle' + } +} diff --git a/docs/releaseAll.md b/docs/releaseAll.md new file mode 100644 index 000000000..b510716ab --- /dev/null +++ b/docs/releaseAll.md @@ -0,0 +1,206 @@ +# Combined Matrix releases + +`releaseAll.sh` publishes all eligible, unreleased, non-SNAPSHOT publications in one +Maven-layout ZIP and one automatic Central Portal deployment. The existing `release.sh` +and module release scripts remain available for individual releases. No version is +rewritten, no baseline is advanced, and no Git tag is created. + +## First run + +1. Install Java 21, Maven 3.9.9 or later, Bash 4 or later, Groovy CLI, Git and grep. + On macOS, run `brew install bash` and put Homebrew's `bin` on PATH before + `/bin`, so `#!/usr/bin/env bash` selects modern Bash. GNU realpath/find are unnecessary. +2. The build resolves the published `nexus-release-plugin` 2.4.0 automatically. + Release mode resolves this plugin exclusively from the Gradle Plugin Portal, + preventing a locally published build from replacing the released version. + To test plugin changes locally, set `RELEASE_ALL_PLUGIN_DIR` to a plugin checkout; + this optional composite build also needs a discoverable JDK 17 toolchain. +3. Set `sonatypeUsername` and `sonatypePassword` in `~/.gradle/gradle.properties`, or set + `SONATYPE_USERNAME` and `SONATYPE_PASSWORD` in the process environment. These must be + Central Portal user-token credentials. Do not put secrets into tracked files. +4. Capture the read-only Portal contract fixtures once: + + ```bash + groovy scripts/release-all/capture-portal-fixture.groovy + ``` + + This checks published `se.alipsa.matrix:matrix-core:3.9.0` and a never-uploaded version, + using the exact groupId as `namespace`. Only parameter values, status, boolean body, + date and official API source are saved to `req/releaseAll-portal-fixture.json`. + Commit the fixture and release versions on your release branch. If credentials, + namespace semantics or the absence response cannot be confirmed, discovery fails + closed when it needs Portal absence. A selection containing only SNAPSHOT versions + does not need credentials or fixtures. +5. Configure Gradle artifact signing through the existing `signing.keyId`, signing + secret-key/passphrase properties. Maven's `release` profile uses your existing + global/user Maven settings and GPG configuration. GPG and an available secret key + are checked before verification when Maven components are selected; dry-run reports + missing setup. Configure the key selection/passphrase through your existing Maven settings. +6. Commit or stash all changes. Run: + + ```bash + ./releaseAll.sh --dry-run + ./releaseAll.sh --bundle-only + ./releaseAll.sh + ``` + + The first command reports exclusions and test scope. The second verifies and creates + the ZIP; the third additionally submits it. Each command freshly plans against current + Central/Portal availability. Do not submit the ZIP separately while using this workflow. + +## Commands and defaults + +| Option or variable | Default | Behavior | +| --- | --- | --- | +| no option | full release | Plan, preflight, scoped verification, local staging, validate, upload once, poll publication and repository propagation. | +| `--dry-run` | off | Generate POMs and report selection; check required test prerequisites. No tests, signing, staging or upload. Authentication preflight may compile gsheets, core and required project dependencies. | +| `--bundle-only` | off | Verify and prepare the validated ZIP without upload. | +| `--resume ` | off | Poll the saved deployment without planning, rebuilding or uploading. Source changes are allowed for status-only recovery. | +| `RUN_EXTERNAL_TESTS` | `true` | Enable normal externally tagged tests. Set `false` explicitly to omit their credentials/network prerequisites and tests. | +| `RUN_SLOW_TESTS` | `true` | Preserve existing slow-test release behavior. Set `false` to override it. | +| `RELEASE_ALL_DEDICATED_EXTERNAL_TESTS` | `false` | Opt in to dedicated BigQuery release tests; requires external tests enabled. Flaky tests are excluded. | +| `RELEASE_ALL_PLUGIN_DIR` | unset | Optional path to a plugin checkout for a development composite build; otherwise uses published 2.4.0. | + +Run from any working directory; the script anchors all paths at its own directory. +For example, an explicitly offline test release is: + +```bash +RUN_EXTERNAL_TESTS=false RUN_SLOW_TESTS=false /path/to/matrix/releaseAll.sh --bundle-only +``` + +## Eligibility and verification + +Every production compile-classpath project dependency is an eligibility edge, including +`compileOnly`, `implementation` and `api`, even if absent from the POM. Published Matrix +POM dependencies and BOM pins are checked too. An exact version must already be released +or be eligible in this bundle. Exclusions cascade to dependants. + +For example, stats `2.5.4` compiled against core `3.10.0-SNAPSHOT` is skipped even if core +`3.9.0` exists in Central. Set the actual core project to the required released version, +or release core's exact non-SNAPSHOT version in the same bundle. Separate-release scripts +retain their existing behavior. + +A selected module's BOM pin must match its publication version. An unselected module may +be pinned to any exact released version: arff `0.3.0` remains valid when the unselected +arff build has advanced to `0.3.1-SNAPSHOT`, with a warning about the difference. + +Verification runs selected Gradle modules plus their transitive production dependants, +including SNAPSHOT dependants for regression testing, excluding examples. CodeNarc runs +before Spotless checks and scoped `build -Pheadless=true`; this includes CSV's non-UTF-8 +encoding check. Unrelated module tests are not release gates. Verification builds disable the configuration cache for the existing test listeners; new planning/bundling tasks are tested with strict cache reuse. Explicit external/slow flags are passed as Gradle properties as well as environment variables. BOM/all releases also run +the existing API verifier, with existing emulator skips and japicmp compatibility warnings. + +External prerequisites are based on actual enabled `@Tag('external')` test methods, checked against `scripts/release-all/external-requirements.json`. New unmapped tests block preflight; disabled, flaky and disabled slow tests add no prerequisites: + +- GSheets needs noninteractive ADC with Sheets, Sheets-readonly and Drive-file grants, + including cleanup. Broader Sheets and Drive grants satisfy their narrower scopes + through the existing authenticator checks. Enable Sheets/Drive APIs and use, for example, + `gcloud auth application-default login --scopes=https://www.googleapis.com/auth/cloud-platform,https://www.googleapis.com/auth/spreadsheets,https://www.googleapis.com/auth/drive.file`. + Set the ADC quota project as needed. Preflight validates scopes and quota handling + using the existing authenticator, then probes Sheets and Drive endpoints. It does not + start a browser login or create spreadsheets. +- Datasets needs access to the Rdatasets overview and CSV endpoints on + `raw.githubusercontent.com`. It needs no ADC. +- Dedicated BigQuery checks additionally require ADC, `GOOGLE_CLOUD_PROJECT` and a + running Docker daemon. The release task excludes flaky tests; the existing + `externalTest` task remains unchanged. +- Current BOM API tests have no external-tagged credentialed Sheets or remote dataset + tests. A BOM/all-only release adds neither ADC nor dataset probes. + +The unpublished `authPreflight` source set has a dedicated statically compiled entry +point, explicit Groovy BOM/runtime, groovy-json and matrix-core dependencies. These +helpers are also used by external tests and excluded from all published artifacts/POMs. +The entry point accepts `--check-classpath` for a noncredentialed runtime wiring check: +`./gradlew :matrix-gsheets:releaseAuthPreflight --args=--check-classpath`. +The release script always runs full authentication preflight with no entry-point arguments. +Offline scope-coverage tests intercept production operations, and an AST inventory checks +qualified and statically imported authentication calls. + +## Outputs and recovery + +Outputs are under ignored `build/releaseAll/`: + +- `manifest.json`: selected/excluded coordinates, dependency origins, verification scope, + source revision, effective versions and tracked-source hashes, without credentials. +- `report.txt`: readable coordinates, every dependency edge/origin, exclusions and test scope. +- `staging/`: signed release files only. Maven accesses this as a releases-only file remote. +- `maven-local/`: isolated Maven downloads/install outputs, separate from staging. +- `bundle.zip` and `bundle-receipt.json`: allow-listed archive and validation hashes. +- `deployment.json`: submission intent, bundle hash, manifest and immediately saved ID/state. + +BOM packaging requires only its POM. Normal JAR modules require main, sources and Javadoc +JARs and POM. `matrix-all` deliberately preserves its existing POM/main JAR artifact set; +Central server validation is authoritative for this compatibility exception. Every artifact +requires its signature. MD5/SHA-1 checksums are generated and existing staged checksums are +verified. Third-party downloads, bookkeeping, module metadata and unrelated coordinates +never enter the ZIP. The local limit is 1 GB (1,000,000,000 bytes). + +Preparation, bundling and new uploads reject changed HEAD, dirty sources, changed versions +or input hashes. Replan from a clean checkout after changing source/version inputs. +The validated ZIP/manifest hashes must still match immediately before upload. Availability +is rechecked against both Central and Portal; 400/401/403 and uncertain responses block, +while transient failures have bounded retries. + +If interrupted after receiving a deployment ID, run: + +```bash +./releaseAll.sh --resume +``` + +If the record says `SUBMITTING` with no ID, the outcome is unknown: inspect Portal and +reconcile the deployment manually. **Do not retry upload.** A failed deployment also +requires inspection/reconciliation before another submission. Preserve the record until +all components are available on Central; repository propagation is distinct from Portal +publication. Archive a completed `AVAILABLE` record outside the active `deployment.json` +path before starting the next release. Never erase an unresolved record to retry. + +Verification settings contain only Central plus the file remote. Signing preparation +copies existing user Maven settings temporarily with owner-only permissions, preserves +global settings, adds the file remote and deletes the copy afterwards. If your configured +mirror intercepts file repositories, exclude `matrix-release-staging` from that mirror's +`mirrorOf` expression before preparation. + +The plugin enhancement is released separately from Matrix. Its published version should +replace the development composite only after that separate release is available; no plugin +publication or Matrix publication is performed during implementation verification. + +## Implementation checks + +The following checks create disposable fixtures under ignored `build/releaseAll/` and +never upload. Run them from the Matrix root after the normal build checks: + +```bash +groovy scripts/release-all/test-tools.groovy . +groovy scripts/release-all/test-workflow.groovy . +groovy scripts/release-all/test-auth-classpath.groovy . +groovy scripts/release-all/test-maven-preparation.groovy . ../nexus-release-plugin +RUN_EXTERNAL_TESTS=false RUN_SLOW_TESTS=false \ + BOM_VERIFY_REPO="$PWD/build/releaseAll/bom-verify" bash matrix-bom/verifyBomApi.sh +groovy scripts/release-all/test-manifest-verifier.groovy . build/releaseAll/bom-verify +``` + +Each script's first argument is the Matrix root (default `.`). Signed preparation's +second argument is the enhanced plugin checkout (default `../nexus-release-plugin`); +it requires GPG and uses a disposable keyring plus fixture signing settings. Manifest +verification's second argument is the isolated repository populated by the preceding +standalone verifier (default `build/releaseAll/bom-verify`). The source-set fixture runs +controlled authentication/HTTP checks and deliberately confirms a bad method signature +fails static compilation. CI runs portable helpers on macOS/Linux and both verifier modes +on Linux. Live Portal fixture capture remains a separate credentialed read-only prerequisite. + +Read-only checkout, home and Maven Local paths may contain symlinks and are +canonicalized during preflight. Staging and verification cache paths being written +or deleted must have no symlink components. Manifest-mode BOM verification checks +matching BOM modules within staging; other selected modules, including matrix-smile +or modules newer than an unselected BOM pin, may coexist there. Its generated Maven +settings live in a fresh temporary directory and are removed on exit. + +The external-test inventory resolves literal JUnit tags and composed annotations defined in +local Groovy test sources (for example `@Slow`). Unresolved custom annotations, container +`@Tags`, nonliteral tag values, and inheritance from a test-source class or interface whose ancestor +tree declares tags or `@Disabled` fail with a diagnostic rather than silently skipping +prerequisites. For tagged inheritance, move tags and `@Disabled` onto concrete test +classes and leave base classes and interfaces untagged. Untagged test bases and helper +inheritance are allowed; superclass and interface names are resolved using packages +and imports, including generic Java types. Parent interfaces are checked recursively. +An empty deployment ID passed to `--resume` prints usage and exits before invoking Gradle. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4598a1ff2..ae91c1044 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] v_aalto-xml = "1.4.0" v_alipsa-maven-module = "0.2.0" -v_alipsa-nexus-release = "2.2.0" +v_alipsa-nexus-release = "2.4.0" v_avro = "1.12.2" v_ben-manes-versions = "0.61.0" v_codenarc = "4.0.0" diff --git a/gradle/release-all.gradle b/gradle/release-all.gradle new file mode 100644 index 000000000..188368bc3 --- /dev/null +++ b/gradle/release-all.gradle @@ -0,0 +1,143 @@ +import groovy.json.JsonOutput +import org.gradle.api.artifacts.ProjectDependency +import org.gradle.api.publish.maven.MavenPublication + +def releaseLoader = rootProject.plugins.findPlugin('se.alipsa.nexus-release-plugin').class.classLoader +def ReleaseManifest = releaseLoader.loadClass('se.alipsa.gradle.plugin.release.ReleaseManifest') +def CentralPortalClient = releaseLoader.loadClass('se.alipsa.gradle.plugin.release.CentralPortalClient') + +// Only loaded in the release workflow. +def output = layout.buildDirectory.dir('releaseAll') +def manifest = output.map { it.file('manifest.json') } +def staged = output.map { it.dir('staging') } +def credentialsUser = providers.gradleProperty('sonatypeUsername').orElse(providers.environmentVariable('SONATYPE_USERNAME')) +def credentialsPassword = providers.gradleProperty('sonatypePassword').orElse(providers.environmentVariable('SONATYPE_PASSWORD')) +def repository = providers.gradleProperty('releaseAllRepositoryUrl').orElse('https://repo.maven.apache.org/maven2') +def portal = providers.gradleProperty('releaseAllPortalUrl').orElse(CentralPortalClient.CENTRAL_PORTAL_URL) + +tasks.named('releaseAllPlan') { + configuredRevision.set(providers.exec { + workingDir(rootDir) + commandLine('git', 'rev-parse', 'HEAD') + }.standardOutput.asText.get().trim()) + sourceDirectory.set(layout.projectDirectory) + bomPom.set(layout.projectDirectory.file('matrix-bom/bom.xml')) + allPom.set(layout.projectDirectory.file('matrix-bom/pom.xml')) + manifestFile.set(manifest) + reportFile.set(output.map { it.file('report.txt') }) + userName.set(credentialsUser) + password.set(credentialsPassword) + repositoryUrl.set(repository) + portalUrl.set(portal) + portalFixture.set(layout.projectDirectory.file('req/releaseAll-portal-fixture.json')) +} +tasks.named('bundleAll') { + receiptFile.set(output.map { it.file('bundle-receipt.json') }) + sourceDirectory.set(layout.projectDirectory) + manifestFile.set(manifest) + stagingDirectory.set(staged) + bundleFile.set(output.map { it.file('bundle.zip') }) +} +tasks.named('releaseAll') { + receiptFile.set(output.map { it.file('bundle-receipt.json') }) + portalFixture.set(layout.projectDirectory.file('req/releaseAll-portal-fixture.json')) + sourceDirectory.set(layout.projectDirectory) + manifestFile.set(manifest) + bundleFile.set(output.map { it.file('bundle.zip') }) + recordFile.set(output.map { it.file('deployment.json') }) + userName.set(credentialsUser) + password.set(credentialsPassword) + portalUrl.set(portal) + repositoryUrl.set(repository) + resumeId.set(providers.gradleProperty('releaseAllResume').orElse('')) +} + +// Discovery records serializable values; actions never access the live Gradle model. +def configureRelease = { + List candidates = [] + subprojects.findAll { !it.path.startsWith(':matrix-examples') }.each { Project module -> + def publishing = module.extensions.findByType(org.gradle.api.publish.PublishingExtension) + if (publishing == null) return + def releaseExtension = module.extensions.findByName('nexusReleasePlugin') + def publication = releaseExtension?.mavenPublication?.present ? releaseExtension.mavenPublication.get() : null + if (publication == null) return + List edges = [] + ['compileClasspath', 'runtimeClasspath'].each { String configuration -> + module.configurations.findByName(configuration)?.hierarchy?.each { origin -> + origin.dependencies.withType(ProjectDependency).each { dependency -> + def target = rootProject.project(dependency.path) + def targetPublication = target.extensions.findByName('nexusReleasePlugin')?.mavenPublication?.getOrNull() + if (targetPublication == null) throw new GradleException("Production project dependency ${module.path} -> ${dependency.path} has no registered release publication") + edges.add([groupId: targetPublication.groupId, artifactId: targetPublication.artifactId, + version: targetPublication.version, origin: "${origin.name} -> ${configuration}".toString()]) + } + } + } + String taskName = "generatePomFileFor${publication.name.capitalize()}Publication" + def pomFile = module.layout.buildDirectory.file("publications/${publication.name}/pom-default.xml") + String pomPath = rootDir.toPath().relativize(pomFile.get().asFile.toPath()).toString() + String prefix = "${publication.artifactId}-${publication.version}" + def metadataTask = module.tasks.findByName("generateMetadataFileFor${publication.name.capitalize()}Publication") + def files = ["${prefix}.pom".toString()] + + (metadataTask?.enabled ? ["${prefix}.module".toString()] : []) + + publication.artifacts.findAll { it.extension == 'jar' }.collect { artifact -> + "${prefix}${artifact.classifier ? '-' + artifact.classifier : ''}.${artifact.extension}".toString() + } + candidates.add([projectPath: module.path, publication: publication.name, pomFile: pomPath, + groupId: publication.groupId, artifactId: publication.artifactId, version: publication.version, + files: files, dependencies: edges.unique()]) + tasks.named('releaseAllPlan') { + dependsOn(module.tasks.named(taskName)) + candidatePoms.from(pomFile) + } + module.publishing.repositories.maven { + name = 'releaseAllStaging' + url = staged.get().asFile.toURI() + } + } + tasks.named('releaseAllPlan') { candidatesJson.set(JsonOutput.toJson(candidates)) } + if (providers.gradleProperty('releaseAllPrepare').isPresent()) { + Map plan = ReleaseManifest.read(manifest.get().asFile) + tasks.named('releaseAllGuard') { + manifestFile.set(manifest) + sourceDirectory.set(layout.projectDirectory) + versionsJson.set(JsonOutput.toJson(candidates.collectEntries { [it.projectPath, it.version] })) + } + subprojects.each { module -> + module.tasks.configureEach { mustRunAfter(rootProject.tasks.named('releaseAllGuard')) } + } + tasks.register('releaseAllStage') { + dependsOn(tasks.named('releaseAllGuard')) + plan.selected.findAll { it.projectPath }.each { component -> + dependsOn("${component.projectPath}:publish${component.publication.capitalize()}PublicationToReleaseAllStagingRepository") + } + } + } +} + +tasks.named('releaseAllPrepareMaven') { + mavenWorkDirectory.set(layout.projectDirectory.dir('matrix-bom')) + verifierFile.set(layout.projectDirectory.file('matrix-bom/verifyBomApi.sh')) + settingsHelperFile.set(layout.projectDirectory.file('scripts/release-all/staged-settings.groovy')) + sourceDirectory.set(layout.projectDirectory) + manifestFile.set(manifest) + stagingDirectory.set(staged) + localRepository.set(output.map { it.dir('maven-local') }) +} + +configureRelease() + +tasks.named('releaseAllSigningCheck') { + Map configured = subprojects.findAll { it.extensions.findByName('signing') != null }.collectEntries { module -> + boolean available + try { + available = module.extensions.getByType(org.gradle.plugins.signing.SigningExtension).signatory != null + } catch (Exception ignored) { + available = false + } + [module.path, available] + } + configuredSigningJson.set(JsonOutput.toJson(configured)) + manifestFile.set(manifest) + strict.set(!providers.gradleProperty('releaseAllSigningReport').isPresent()) +} diff --git a/matrix-bigquery/build.gradle b/matrix-bigquery/build.gradle index 25673d569..f69fb477d 100644 --- a/matrix-bigquery/build.gradle +++ b/matrix-bigquery/build.gradle @@ -176,3 +176,14 @@ nexusReleasePlugin { password = project.ext.properties.sonatypePassword mavenPublication = publishing.publications.maven } + +// The existing externalTest still includes flaky tests; releases use this opt-in gate. +tasks.register('releaseExternalTest', Test) { + group = 'verification' + testClassesDirs = sourceSets.test.output.classesDirs + classpath = sourceSets.test.runtimeClasspath + useJUnitPlatform { + includeTags 'external' + excludeTags 'flaky' + } +} diff --git a/matrix-bom/readme.md b/matrix-bom/readme.md index 403157637..554c53286 100644 --- a/matrix-bom/readme.md +++ b/matrix-bom/readme.md @@ -84,9 +84,11 @@ dependency with its own version rather than via the BOM or `matrix-all`. ## Verifying a release The BOM consumer suite verifies the resolved, published artifacts from an isolated Maven repository. -The verification runner requires Bash 4+, JDK 21+, Maven, and the Groovy CLI on `PATH` (matching the -project's Groovy version, currently 5.0.8). The Groovy CLI is a developer prerequisite; the Gradle -Groovy dependency alone is not sufficient. +The verification runner requires Bash 4+, JDK 21+, Maven 3.9.9+, grep, and the Groovy CLI on `PATH` (matching the +project's Groovy version, currently 5.1.1). The Groovy CLI is a developer prerequisite; the Gradle +Groovy dependency alone is not sufficient. On macOS, install Bash with +`brew install bash`. Symlinked checkout and Maven Local paths are allowed for +read-only path checks; repositories being written or deleted must have no symlink components. From the repository root, run: diff --git a/matrix-bom/verifyBomApi.sh b/matrix-bom/verifyBomApi.sh index 04932dc4c..6137c8886 100755 --- a/matrix-bom/verifyBomApi.sh +++ b/matrix-bom/verifyBomApi.sh @@ -16,11 +16,14 @@ if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then docker_available=true fi -BOM_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +BOM_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P) ROOT_DIR=$(dirname "$BOM_DIR") USER_REPO_SET=${BOM_VERIFY_REPO+x} REPO="${BOM_VERIFY_REPO:-$ROOT_DIR/.bom-verify-repo}" cd "$BOM_DIR" +RELEASE_ROOT=$ROOT_DIR +source "$ROOT_DIR/scripts/release-all/preflight.sh" +release_tools_preflight bom reject_dotdot() { case "/$1/" in @@ -31,13 +34,6 @@ reject_dotdot() { esac } -canonicalize() { - command -v realpath >/dev/null || { - echo "realpath(1) is required to validate BOM_VERIFY_REPO" >&2 - exit 1 - } - realpath -m -- "$1" -} reject_symlink_components() { local path=$1 @@ -66,14 +62,18 @@ assert_safe_repo_path() { exit 1 fi - REPO=$(canonicalize "$REPO") - local root bom m2 - root=$(canonicalize "$ROOT_DIR") - bom=$(canonicalize "$BOM_DIR") - m2=$(canonicalize "${HOME}/.m2/repository") + local normalized root bom m2 user_home + normalized=$(RELEASE_PATHS_STRICT_COUNT=1 RELEASE_PATHS_LINES=true groovy "$ROOT_DIR/scripts/release-all/Paths.groovy" "$REPO" "$ROOT_DIR" "$BOM_DIR" "${HOME}/.m2/repository" "$HOME") + local paths + mapfile -t paths <<< "$normalized" + REPO=${paths[0]} + root=${paths[1]} + bom=${paths[2]} + m2=${paths[3]} + user_home=${paths[4]} [[ "$REPO" != "/" ]] || { echo "refusing to delete /" >&2; exit 1; } - [[ "$REPO" != "$(canonicalize "$HOME")" ]] || { echo 'refusing to delete $HOME' >&2; exit 1; } + [[ "$REPO" != "$user_home" ]] || { echo 'refusing to delete the home directory' >&2; exit 1; } [[ "$REPO" != "$root" ]] || { echo "refusing to delete the repository root" >&2; exit 1; } [[ "$REPO" != "$bom" ]] || { echo "refusing to delete matrix-bom" >&2; exit 1; } [[ "$REPO" != "$m2" ]] || { echo "refusing to delete the real local repo" >&2; exit 1; } @@ -126,13 +126,28 @@ done <<< "$all_output" declare -a releasing=() declare -A release_versions=() -module_override=false -if (( $# > 0 )); then +manifest_mode=false +if (( $# > 0 )) && [[ "$1" == --manifest ]]; then + [[ $# == 6 && "$3" == --staging && "$5" == --local ]] || { echo 'usage: --manifest FILE --staging DIR --local DIR' >&2; exit 1; } + manifest_mode=true + manifest_file=$2 + staging_repo=$4 + REPO=$6 + manifest_properties=$(groovy "$ROOT_DIR/scripts/release-all/manifest-bom.groovy" "$manifest_file" "$BOM_DIR/bom.xml" "$staging_repo") + while IFS='=' read -r property value; do + [[ -n "$property" ]] || continue + if [[ "$property" == __bom_selected ]]; then + manifest_bom_selected=$value + continue + fi + releasing+=("$property") + release_versions["$property"]=$value + done <<< "$manifest_properties" +elif (( $# > 0 )); then [[ "$1" == "--modules" && $# == 2 ]] || { echo "usage: $0 [--modules property[,property...]]" >&2 exit 1 } - module_override=true if [[ "$2" != "none" ]]; then IFS=',' read -r -a override_properties <<< "$2" for property in "${override_properties[@]}"; do @@ -164,6 +179,11 @@ if [[ "$docker_available" != true ]]; then fi it_excluded_groups_csv=$(IFS=,; printf '%s' "${it_excluded_groups[*]}") +if [[ "$manifest_mode" == true ]]; then + # Neither cache nor staging is ever deleted in manifest mode. + RELEASE_PATHS_RELEASE=true groovy "$ROOT_DIR/scripts/release-all/Paths.groovy" "$staging_repo" "$REPO" "$ROOT_DIR" "$BOM_DIR" "$HOME" "$HOME/.m2/repository" >/dev/null + mkdir -p "$REPO" +else assert_safe_repo_path if [[ "${BOM_VERIFY_FULL_WIPE:-false}" == true ]]; then @@ -179,7 +199,9 @@ if [[ ! -e "$REPO/.matrix-bom-verify-repo" ]]; then : > "$REPO/.matrix-bom-verify-repo" fi -echo "BOM API verification ($WIPE_MODE)" +fi + +echo "BOM API verification (${WIPE_MODE:-manifest})" echo "repository: $REPO" if [[ "$docker_available" == true ]]; then echo "Docker: available — emulator API tests enabled" @@ -207,27 +229,40 @@ for property in "${releasing[@]}"; do } tasks+=(":$module:publishToMavenLocal") done -if (( ${#tasks[@]} == 0 )); then +if [[ "$manifest_mode" == true ]]; then + echo "using existing staged release artifacts — no Gradle publishing" +elif (( ${#tasks[@]} == 0 )); then echo "no modules under release — skipping the Gradle publish" else "$ROOT_DIR/gradlew" -p "$ROOT_DIR" -Dmaven.repo.local="$REPO" "${tasks[@]}" fi -matrix_repo="$REPO/se/alipsa/matrix" +matrix_repo="${staging_repo:-$REPO}/se/alipsa/matrix" +listing_paths=("$matrix_repo") +for property in "${releasing[@]}"; do + listing_paths+=("$matrix_repo/$(property_to_module "$property")") +done +listing_output=$(RELEASE_PATHS_TSV=true groovy "$ROOT_DIR/scripts/release-all/Paths.groovy" "${listing_paths[@]}") +declare -A path_entries=() path_directories=() +while IFS='|' read -r listing_path entries directories; do + path_entries["$listing_path"]=$entries + path_directories["$listing_path"]=$directories +done <<< "$listing_output" if (( ${#releasing[@]} == 0 )); then - if [[ -e "$matrix_repo" ]] && find "$matrix_repo" -mindepth 1 -print -quit | grep -q .; then + if [[ "$manifest_mode" != true && -e "$matrix_repo" ]] && find "$matrix_repo" -mindepth 1 -print -quit | grep -q .; then echo "$matrix_repo is not empty although no modules were under release" >&2 exit 1 fi else [[ -d "$matrix_repo" ]] || { echo "missing published repository subtree: $matrix_repo" >&2; exit 1; } - mapfile -t actual_entries < <(find "$matrix_repo" -mindepth 1 -maxdepth 1 -printf '%f\n' | sort) + IFS=',' read -r -a actual_entries <<< "${path_entries[$matrix_repo]-}" expected_entries=() for property in "${releasing[@]}"; do expected_entries+=("$(property_to_module "$property")") done mapfile -t expected_entries < <(printf '%s\n' "${expected_entries[@]}" | sort) - [[ "${actual_entries[*]-}" == "${expected_entries[*]-}" ]] || { + # Manifest staging also includes selected modules absent from, or newer than, BOM pins. + [[ "$manifest_mode" == true || "${actual_entries[*]-}" == "${expected_entries[*]-}" ]] || { echo "published module set does not match detected BOM properties" >&2 echo "expected: ${expected_entries[*]-}" >&2 echo "actual: ${actual_entries[*]-}" >&2 @@ -240,7 +275,7 @@ else module_dir="$matrix_repo/$module" version_dir="$module_dir/$version" [[ -d "$version_dir" ]] || { echo "missing published version: $module:$version" >&2; exit 1; } - mapfile -t version_entries < <(find "$module_dir" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort) + IFS=',' read -r -a version_entries <<< "${path_directories[$module_dir]-}" [[ "${version_entries[*]-}" == "$version" ]] || { echo "$module contains an unexpected version directory: ${version_entries[*]-}" >&2 exit 1 @@ -250,8 +285,19 @@ else done fi -MVN_ISOLATED=(-s "$BOM_DIR/verify-settings.xml" -gs "$BOM_DIR/verify-settings.xml" -Dmaven.repo.local="$REPO") -mvn "${MVN_ISOLATED[@]}" -f bom.xml install +verification_settings="$BOM_DIR/verify-settings.xml" +if [[ "$manifest_mode" == true ]]; then + settings_directory=$(mktemp -d "${TMPDIR:-/tmp}/matrix-bom-settings.XXXXXX") + verification_settings="$settings_directory/verify-settings.xml" + trap 'rm -f "$verification_settings"; rmdir "$settings_directory"' EXIT + groovy "$ROOT_DIR/scripts/release-all/staged-settings.groovy" "$BOM_DIR/verify-settings.xml" "$staging_repo" "$verification_settings" +fi +MVN_ISOLATED=(-s "$verification_settings" -gs "$verification_settings" -Dmaven.repo.local="$REPO") +if [[ "$manifest_mode" != true || "${manifest_bom_selected:-false}" == true ]]; then + mvn "${MVN_ISOLATED[@]}" -f bom.xml install +else + echo 'unselected matching BOM resolves from Central; not installing the source BOM' +fi mvn "${MVN_ISOLATED[@]}" -Papi-it -Dit.excludedGroups="$it_excluded_groups_csv" clean verify japicmp_new= @@ -282,13 +328,13 @@ else echo "japicmp failed to run (exit $japicmp_rc) — infrastructure problem, not a compatibility result" >&2 exit 1 fi - if rg -q -e 'binaryCompatible="false"|sourceCompatible="false"' "$japicmp_report"; then + if grep -Eq -e 'binaryCompatible="false"|sourceCompatible="false"' "$japicmp_report"; then echo "japicmp: COMPATIBILITY CHANGES FOUND in matrix-core — review before releasing:" japicmp_diff=japicmp/target/japicmp/cmp.diff if [[ -f "$japicmp_diff" ]]; then changed_entries=$( - rg '^[[:space:]]*===\*' "$japicmp_diff" | - rg -v '===\* UNCHANGED CLASS:' | + grep '^[[:space:]]*===\*' "$japicmp_diff" | + grep -v '===\* UNCHANGED CLASS:' | sed -E \ -e 's/^[[:space:]]*===\* UNCHANGED (METHOD|CONSTRUCTOR|FIELD):/ AFFECTED \1:/' \ -e 's/^[[:space:]]*===\* / /' || true diff --git a/matrix-gsheets/build.gradle b/matrix-gsheets/build.gradle index 6c4f1124d..a11d35d49 100644 --- a/matrix-gsheets/build.gradle +++ b/matrix-gsheets/build.gradle @@ -229,3 +229,45 @@ nexusReleasePlugin { password = project.ext.properties.sonatypePassword mavenPublication = publishing.publications.maven } + +// Release-only helpers remain outside every published artifact and publication dependency. +sourceSets { + authPreflight { + groovy.srcDir 'src/authPreflight/groovy' + compileClasspath += sourceSets.main.output + sourceSets.main.runtimeClasspath + runtimeClasspath += sourceSets.main.output + sourceSets.main.runtimeClasspath + } +} +configurations { + authPreflightExecution +} +dependencies { + authPreflightImplementation platform(libs.groovy.bom) + authPreflightRuntimeOnly platform(libs.groovy.bom) + authPreflightImplementation libs.groovy + authPreflightImplementation libs.groovy.json + authPreflightImplementation project(':matrix-core') + authPreflightExecution platform(libs.groovy.bom) + authPreflightExecution libs.groovy + authPreflightExecution libs.groovy.json + authPreflightExecution project(':matrix-core') + testImplementation sourceSets.authPreflight.output +} +compileAuthPreflightGroovy { + groovyOptions.configurationScript = rootProject.file('config/groovy/compileStatic.groovy') +} +tasks.register('releaseAuthPreflight', JavaExec) { + group = 'verification' + description = 'Check external Sheets ADC scopes and endpoints without interactive login' + dependsOn tasks.named('authPreflightClasses'), tasks.named('classes') + classpath = sourceSets.authPreflight.runtimeClasspath + configurations.authPreflightExecution + mainClass = 'se.alipsa.matrix.gsheets.preflight.AuthPreflight' +} + +tasks.named('test', Test) { + def inventoryClasspath = sourceSets.main.compileClasspath + inputs.files(inventoryClasspath).withPropertyName('authInventoryClasspath').withNormalizer(ClasspathNormalizer) + jvmArgumentProviders.add({ + ["-DauthInventoryClasspath=${inventoryClasspath.asPath}".toString()] + } as org.gradle.process.CommandLineArgumentProvider) +} diff --git a/matrix-gsheets/src/authPreflight/groovy/se/alipsa/matrix/gsheets/preflight/AuthPreflight.groovy b/matrix-gsheets/src/authPreflight/groovy/se/alipsa/matrix/gsheets/preflight/AuthPreflight.groovy new file mode 100644 index 000000000..7dd74aacd --- /dev/null +++ b/matrix-gsheets/src/authPreflight/groovy/se/alipsa/matrix/gsheets/preflight/AuthPreflight.groovy @@ -0,0 +1,51 @@ +package se.alipsa.matrix.gsheets.preflight + +import se.alipsa.matrix.core.util.Logger +import se.alipsa.matrix.gsheets.GsAuthenticator + +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.time.Duration + +/** Noninteractive release authentication and network preflight; never creates spreadsheets. */ +class AuthPreflight { + private static final Duration TIMEOUT = Duration.ofSeconds(15) + private static final Logger log = Logger.getLogger(AuthPreflight) + + /** + * Checks enabled Sheets tests' shared scope union and both service endpoints. + * @param args optional {@code --check-classpath} verifies dependencies without authentication or probes + */ + static void main(String[] args) { + if (args.toList() == ['--check-classpath']) { + [groovy.json.JsonSlurper, GsAuthenticator, Logger].each { Class dependency -> + dependency.name + } + ExternalAuthRequirements.scopes() + log.info('Authentication preflight runtime classpath resolved') + return + } + if (args.length > 0) { + throw new IllegalArgumentException('Supported argument: --check-classpath (no authentication)') + } + try { + GsAuthenticator.authenticate(ExternalAuthRequirements.scopes()) + } catch (Exception ignored) { + throw new IllegalStateException('Sheets external tests require noninteractive ADC with Sheets and Drive-file scopes; see docs/releaseAll.md') + } + HttpClient client = HttpClient.newBuilder().connectTimeout(TIMEOUT).build() + ['https://sheets.googleapis.com/', 'https://www.googleapis.com/drive/v3/files'].each { String endpoint -> + try { + HttpRequest request = HttpRequest.newBuilder(URI.create(endpoint)).timeout(TIMEOUT).GET().build() + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()) + if (response.statusCode() >= 500) { + throw new IOException('Service unavailable') + } + } catch (Exception ignored) { + throw new IllegalStateException("External test endpoint is unavailable: ${endpoint}") + } + } + log.info('Sheets external test authentication and network preflight passed') + } +} diff --git a/matrix-gsheets/src/authPreflight/groovy/se/alipsa/matrix/gsheets/preflight/ExternalAuthRequirements.groovy b/matrix-gsheets/src/authPreflight/groovy/se/alipsa/matrix/gsheets/preflight/ExternalAuthRequirements.groovy new file mode 100644 index 000000000..6e88a527a --- /dev/null +++ b/matrix-gsheets/src/authPreflight/groovy/se/alipsa/matrix/gsheets/preflight/ExternalAuthRequirements.groovy @@ -0,0 +1,12 @@ +package se.alipsa.matrix.gsheets.preflight + +import se.alipsa.matrix.gsheets.GsAuthenticator + +/** Shared credentials required by external Sheets operations, including cleanup. */ +class ExternalAuthRequirements { + + /** Returns all scopes needed by enabled external Sheets tests. */ + static List scopes() { + (GsAuthenticator.SCOPES + [GsAuthenticator.SCOPE_SHEETS_READONLY, GsAuthenticator.SCOPE_DRIVE_FILE]).unique() + } +} diff --git a/matrix-gsheets/src/test/groovy/test/alipsa/matrix/gsheets/AuthPreflightTest.groovy b/matrix-gsheets/src/test/groovy/test/alipsa/matrix/gsheets/AuthPreflightTest.groovy new file mode 100644 index 000000000..1717aac02 --- /dev/null +++ b/matrix-gsheets/src/test/groovy/test/alipsa/matrix/gsheets/AuthPreflightTest.groovy @@ -0,0 +1,85 @@ +package test.alipsa.matrix.gsheets + +import static org.junit.jupiter.api.Assertions.* + +import com.google.auth.oauth2.AccessToken +import com.google.auth.oauth2.GoogleCredentials +import org.junit.jupiter.api.Test +import org.mockito.MockedStatic +import org.mockito.Mockito + +import se.alipsa.matrix.gsheets.GsAuthUtils +import se.alipsa.matrix.gsheets.GsAuthenticator +import se.alipsa.matrix.gsheets.SheetOperationException +import se.alipsa.matrix.gsheets.preflight.AuthPreflight +import se.alipsa.matrix.gsheets.preflight.ExternalAuthRequirements + +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse + +class AuthPreflightTest { + + @Test + void currentUnionIncludesCleanupAndAcceptsBroaderGrants() { + GoogleCredentials credentials = GoogleCredentials.create(new AccessToken('preflight-test', new Date(System.currentTimeMillis() + 3600000))) + Set grants = [GsAuthenticator.SCOPE_SHEETS] as Set + def backend = [ + existing: { List scopes -> credentials }, + login: { List scopes, String project -> fail('Preflight must never log in') }, + hasAllScopes: { GoogleCredentials creds, List scopes -> + GsAuthUtils.hasAllScopes(creds, scopes, { String token -> grants } as GsAuthUtils.ScopeResolver) + }, + userEmail: { GoogleCredentials creds -> fail('Preflight must not request user info') } + ] as GsAuthenticator.AuthBackend + assertThrows(SheetOperationException) { GsAuthenticator.authenticate(ExternalAuthRequirements.scopes(), null, backend) } + // Use a different token so the existing tokeninfo cache does not retain the first grant set. + credentials = GoogleCredentials.create(new AccessToken('preflight-broader-test', new Date(System.currentTimeMillis() + 3600000))) + grants = [GsAuthenticator.SCOPE_SHEETS, 'https://www.googleapis.com/auth/drive'] as Set + assertSame(credentials, GsAuthenticator.authenticate(ExternalAuthRequirements.scopes(), null, backend)) + } + + @Test + void entryPointUsesSharedScopesAndProbesSheetsAndDrive() { + List requested = [] + List endpoints = [] + def authentication = Mockito.mockStatic(GsAuthenticator) + def http = Mockito.mockStatic(HttpClient) + try { + authentication.when({ GsAuthenticator.authenticate(Mockito.anyList()) } as MockedStatic.Verification).thenAnswer { call -> + requested.addAll(call.getArgument(0) as List) + null + } + HttpClient.Builder builder = Mockito.mock(HttpClient.Builder, Mockito.RETURNS_SELF) + HttpClient client = Mockito.mock(HttpClient) + HttpResponse response = Mockito.mock(HttpResponse) + http.when({ HttpClient.newBuilder() } as MockedStatic.Verification).thenReturn(builder) + Mockito.when(builder.build()).thenReturn(client) + Mockito.when(response.statusCode()).thenReturn(401) + Mockito.when(client.send(Mockito.any(HttpRequest), Mockito.any(HttpResponse.BodyHandler))).thenAnswer { call -> + endpoints.add((call.getArgument(0) as HttpRequest).uri()) + response + } + AuthPreflight.main(new String[0]) + assertEquals(ExternalAuthRequirements.scopes(), requested) + assertTrue(endpoints.any { it.host == 'sheets.googleapis.com' }) + assertTrue(endpoints.any { it.path.startsWith('/drive/v3') }) + } finally { + http.close() + authentication.close() + } + } + + @Test + void entryPointSanitizesCredentialFailures() { + def authentication = Mockito.mockStatic(GsAuthenticator) + try { + authentication.when({ GsAuthenticator.authenticate(Mockito.anyList()) } as MockedStatic.Verification).thenThrow(new IllegalStateException('sensitive-token')) + IllegalStateException failure = assertThrows(IllegalStateException) { AuthPreflight.main(new String[0]) } + assertFalse(failure.message.contains('sensitive-token')) + assertNull(failure.cause) + } finally { + authentication.close() + } + } +} diff --git a/matrix-gsheets/src/test/groovy/test/alipsa/matrix/gsheets/AuthenticationInventoryTest.groovy b/matrix-gsheets/src/test/groovy/test/alipsa/matrix/gsheets/AuthenticationInventoryTest.groovy new file mode 100644 index 000000000..e91e58306 --- /dev/null +++ b/matrix-gsheets/src/test/groovy/test/alipsa/matrix/gsheets/AuthenticationInventoryTest.groovy @@ -0,0 +1,97 @@ +package test.alipsa.matrix.gsheets + +import static org.junit.jupiter.api.Assertions.* + +import org.codehaus.groovy.ast.ClassCodeVisitorSupport +import org.codehaus.groovy.ast.expr.DeclarationExpression +import org.codehaus.groovy.ast.expr.MethodCallExpression +import org.codehaus.groovy.ast.expr.StaticMethodCallExpression +import org.codehaus.groovy.ast.expr.VariableExpression +import org.codehaus.groovy.control.CompilerConfiguration +import org.codehaus.groovy.control.SourceUnit +import org.junit.jupiter.api.Test + +class AuthenticationInventoryTest { + private static final String OWNER = 'se.alipsa.matrix.gsheets.GsAuthenticator' + + @Test + void productionCallSitesAreMapped() { + List inventory = [] + new File('src/main/groovy').eachFileRecurse { File source -> + if (source.name.endsWith('.groovy') && source.name != 'GsAuthenticator.groovy') { + inventory.addAll(calls(source.text)) + } + } + assertEquals([ + 'se.alipsa.matrix.gsheets.GsUtil#deleteSheet|authenticate(scopes)', + 'se.alipsa.matrix.gsheets.GsUtil#getSheetNames|authenticate(GsAuthenticator.SCOPE_SHEETS_READONLY)', + 'se.alipsa.matrix.gsheets.GsheetsReader#buildSheetsService|authenticate(GsAuthenticator.SCOPE_SHEETS_READONLY)', + 'se.alipsa.matrix.gsheets.GsheetsWriter#buildSheetsService|authenticate(SCOPES)' + ].sort(), inventory.sort(), 'Map every added authentication call to an exercised public operation') + } + + @Test + void resolvesNamedAliasedAndWildcardImportsAndShadowing() { + assertEquals(['Example#run|authenticate([])'], calls("import static ${OWNER}.authenticate\nclass Example { void run() { authenticate([]) } }")) + assertEquals(['Example#run|auth([])'], calls("import static ${OWNER}.authenticate as auth\nclass Example { void run() { auth([]) } }")) + assertEquals(['Example#run|authenticate([])'], calls("import static ${OWNER}.*\nclass Example { void run() { authenticate([]) } }")) + assertTrue(calls("import static ${OWNER}.*\nclass Example { void authenticate(List scopes) {}\nvoid run() { authenticate([]) } }").empty) + assertTrue(calls('class Example { void run() { def authenticate = { List scopes -> scopes }; authenticate([]) } }').empty) + assertTrue(calls("import static ${OWNER}.*\nclass Example { void run() { def authenticate = { List scopes -> scopes }; authenticate([]) } }").empty) + assertTrue(calls('// GsAuthenticator.authenticate([])\nclass Example {}').empty) + } + + private static List calls(String text) { + CompilerConfiguration configuration = new CompilerConfiguration() + configuration.classpath = System.getProperty('authInventoryClasspath', System.getProperty('java.class.path')) + SourceUnit unit = new SourceUnit('Inventory.groovy', text, configuration, null, null) + unit.parse() + unit.completePhase() + unit.nextPhase() + unit.convert() + List found = [] + unit.AST.classes.each { cls -> + def callableBodies = (cls.methods + cls.declaredConstructors).collect { + [name: it.name, parameters: it.parameters, code: it.code] + } + cls.fields.collect { [name: "", parameters: [], code: it.initialExpression] } + callableBodies.each { method -> + Set localNames = (method.parameters*.name + cls.fields*.name) as Set + method.code?.visit(new ClassCodeVisitorSupport() { + @Override + protected SourceUnit getSourceUnit() { unit } + + @Override + void visitDeclarationExpression(DeclarationExpression declaration) { + if (declaration.leftExpression instanceof VariableExpression) { + localNames.add(declaration.leftExpression.name) + } + super.visitDeclarationExpression(declaration) + } + + @Override + void visitMethodCallExpression(MethodCallExpression call) { + String name = call.methodAsString + def named = unit.AST.staticImports[name] + boolean imported = named?.type?.name == AuthenticationInventoryTest.OWNER && named.fieldName == 'authenticate' + boolean wildcard = named == null && name == 'authenticate' && unit.AST.staticStarImports.values().any { it.type.name == AuthenticationInventoryTest.OWNER } + boolean qualified = name == 'authenticate' && call.objectExpression.text in [AuthenticationInventoryTest.OWNER, 'GsAuthenticator'] + boolean shadowed = localNames.contains(name) || cls.methods.any { it.name == name } || call.objectExpression.text != 'this' + if (qualified || ((imported || wildcard) && !shadowed)) { + found.add("${cls.name}#${method.name}|${name}${call.arguments.text}".toString()) + } + super.visitMethodCallExpression(call) + } + + @Override + void visitStaticMethodCallExpression(StaticMethodCallExpression call) { + if (call.ownerType.name == AuthenticationInventoryTest.OWNER && call.method == 'authenticate') { + found.add("${cls.name}#${method.name}|${call.method}${call.arguments.text}".toString()) + } + super.visitStaticMethodCallExpression(call) + } + }) + } + } + found + } +} diff --git a/matrix-gsheets/src/test/groovy/test/alipsa/matrix/gsheets/GsTest.groovy b/matrix-gsheets/src/test/groovy/test/alipsa/matrix/gsheets/GsTest.groovy index e17b8cce4..5a485fe53 100644 --- a/matrix-gsheets/src/test/groovy/test/alipsa/matrix/gsheets/GsTest.groovy +++ b/matrix-gsheets/src/test/groovy/test/alipsa/matrix/gsheets/GsTest.groovy @@ -3,18 +3,26 @@ package test.alipsa.matrix.gsheets import static org.junit.jupiter.api.Assertions.* import static se.alipsa.matrix.core.ListConverter.toLocalDates +import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import se.alipsa.matrix.core.Matrix +import se.alipsa.matrix.gsheets.GsAuthenticator import se.alipsa.matrix.gsheets.GsUtil import se.alipsa.matrix.gsheets.GsheetsReader import se.alipsa.matrix.gsheets.GsheetsWriter +import se.alipsa.matrix.gsheets.preflight.ExternalAuthRequirements import java.time.LocalDate @Tag('external') class GsTest { + @BeforeAll + static void authenticateExternalScopes() { + GsAuthenticator.authenticate(ExternalAuthRequirements.scopes()) + } + def empData = Matrix.builder() .matrixName('empData') diff --git a/matrix-gsheets/src/test/groovy/test/alipsa/matrix/gsheets/GsUtilTest.groovy b/matrix-gsheets/src/test/groovy/test/alipsa/matrix/gsheets/GsUtilTest.groovy index 409ccbe95..0185cbb73 100644 --- a/matrix-gsheets/src/test/groovy/test/alipsa/matrix/gsheets/GsUtilTest.groovy +++ b/matrix-gsheets/src/test/groovy/test/alipsa/matrix/gsheets/GsUtilTest.groovy @@ -8,9 +8,11 @@ import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import se.alipsa.matrix.core.Matrix +import se.alipsa.matrix.gsheets.GsAuthenticator import se.alipsa.matrix.gsheets.GsConverter import se.alipsa.matrix.gsheets.GsExporter import se.alipsa.matrix.gsheets.GsUtil +import se.alipsa.matrix.gsheets.preflight.ExternalAuthRequirements import java.time.LocalDate import java.time.LocalDateTime @@ -57,6 +59,7 @@ class GsUtilTest { @Test @Tag('external') void testGetSheetNames() { + GsAuthenticator.authenticate(ExternalAuthRequirements.scopes()) // Create a test spreadsheet with multiple sheets def empData = Matrix.builder() .matrixName('Employee Data') @@ -94,6 +97,7 @@ class GsUtilTest { @Test @Tag('external') void testGetSheetNamesWithMultipleSheets() { + GsAuthenticator.authenticate(ExternalAuthRequirements.scopes()) // This test verifies getSheetNames works with spreadsheets containing multiple sheets // Note: Currently GsExporter creates one sheet per export // This test creates a spreadsheet and verifies at least one sheet is returned diff --git a/matrix-gsheets/src/test/groovy/test/alipsa/matrix/gsheets/ReleaseScopeCoverageTest.groovy b/matrix-gsheets/src/test/groovy/test/alipsa/matrix/gsheets/ReleaseScopeCoverageTest.groovy new file mode 100644 index 000000000..9934dc10f --- /dev/null +++ b/matrix-gsheets/src/test/groovy/test/alipsa/matrix/gsheets/ReleaseScopeCoverageTest.groovy @@ -0,0 +1,60 @@ +package test.alipsa.matrix.gsheets + +import static org.junit.jupiter.api.Assertions.assertThrows +import static org.junit.jupiter.api.Assertions.assertTrue + +import com.google.auth.oauth2.AccessToken +import com.google.auth.oauth2.GoogleCredentials +import org.junit.jupiter.api.Test +import org.mockito.MockedStatic +import org.mockito.Mockito + +import se.alipsa.matrix.core.Matrix +import se.alipsa.matrix.gsheets.* +import se.alipsa.matrix.gsheets.preflight.ExternalAuthRequirements + +class ReleaseScopeCoverageTest { + + @Test + void productionOperationsRequestCoveredScopes() { + Matrix matrix = Matrix.builder('Scope test').data(x: [1]).types([Integer]).build() + List operations = [ + { new GsUtilTest().testGetSheetNames() }, + { new GsUtilTest().testGetSheetNamesWithMultipleSheets() }, + { GsUtil.deleteSheet('id') }, + { GsUtil.getSheetNames('id') }, + { GsheetsReader.read('id', 'A1', true) }, + { GsheetsReader.readAsStrings('id', 'A1', true) }, + { GsheetsReader.readAsObject('id', 'A1', true) }, + { GsheetsWriter.write(matrix) }, + { GsheetsWriter.update('id', 'A1', matrix) }, + { GsImporter.importSheet('id', 'A1', true) }, + { GsImporter.importSheetAsStrings('id', 'A1', true) }, + { GsImporter.importSheetAsObject('id', 'A1', true) }, + { GsExporter.exportSheet(matrix) }, + { GsAuthenticator.authenticate(GsAuthenticator.SCOPE_SHEETS_READONLY) } + ] + operations.each { Closure operation -> + List recorded = [] + // Intercept List calls directly: Mockito.when with CALLS_REAL_METHODS would invoke ADC while stubbing. + MockedStatic mock = Mockito.mockStatic(GsAuthenticator, { invocation -> + if (invocation.method.name == 'authenticate' && invocation.method.parameterTypes.toList() == [List]) { + recorded.addAll(invocation.getArgument(0) as List) + throw new AuthenticationReached() + } + Mockito.CALLS_REAL_METHODS.answer(invocation) + } as org.mockito.stubbing.Answer) + try { + assertThrows(AuthenticationReached) { operation.call() } + assertTrue(!recorded.empty, 'Production operation must reach List authentication interception') + GoogleCredentials credentials = GoogleCredentials.create(new AccessToken('scope-test', new Date(System.currentTimeMillis() + 3600000))) + assertTrue(GsAuthUtils.hasAllScopes(credentials, recorded, { String token -> ExternalAuthRequirements.scopes() as Set } as GsAuthUtils.ScopeResolver)) + } finally { + mock.close() + } + } + } + + private static class AuthenticationReached extends RuntimeException { + } +} diff --git a/matrix-pict/src/test/groovy/chart/PlotWithoutJavafxTest.groovy b/matrix-pict/src/test/groovy/chart/PlotWithoutJavafxTest.groovy index 6acc707a1..9223497d6 100644 --- a/matrix-pict/src/test/groovy/chart/PlotWithoutJavafxTest.groovy +++ b/matrix-pict/src/test/groovy/chart/PlotWithoutJavafxTest.groovy @@ -17,7 +17,7 @@ class PlotWithoutJavafxTest { @Test void svgExportsWithoutJavafx(@TempDir Path dir) { - assertThrows(ClassNotFoundException) { Class.forName('javafx.scene.Group') } + assertThrows(ClassNotFoundException) { getClass().classLoader.loadClass('javafx.scene.Group') } Matrix data = Matrix.builder().columns([x: [1, 2, 3], y: [2, 4, 3]]) .types([Integer, Integer]).build() def chart = LineChart.builder(data).title('No JavaFX').x('x').y('y').build() diff --git a/readme.md b/readme.md index 5a8169057..1e6fe139d 100644 --- a/readme.md +++ b/readme.md @@ -40,6 +40,8 @@ The Matrix project consists of the following modules: 1. _[matrix-xchart](https://github.com/Alipsa/matrix/blob/main/matrix-xchart/README.md)_ allows you to create charts in various formats (file, svg, swing) based on Matrix data and the [XChart library](https://github.com/knowm/XChart). ## Setup +Maintainers can use [`releaseAll.sh`](docs/releaseAll.md) to release eligible modules together in one Central bundle. + Matrix targets Groovy 5 and JDK 21. Binary builds can be downloaded from the [Matrix project release page](https://github.com/Alipsa/matrix/releases) but if you use a build system that handles dependencies via maven central (gradle, maven ivy etc.) you can add your dependencies from there diff --git a/releaseAll.sh b/releaseAll.sh new file mode 100755 index 000000000..807e36c28 --- /dev/null +++ b/releaseAll.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +set -euo pipefail +RELEASE_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P) +cd "$RELEASE_ROOT" +source "$RELEASE_ROOT/scripts/release-all/preflight.sh" +usage() { + echo 'usage: ./releaseAll.sh [--dry-run|--bundle-only|--resume deployment-id]' >&2 + exit 2 +} +mode=release +resume= +case "${1:-}" in + '') [[ $# == 0 ]] || usage ;; + --dry-run) mode=plan; [[ $# == 1 ]] || usage ;; + --bundle-only) mode=bundle; [[ $# == 1 ]] || usage ;; + --resume) mode=resume; [[ $# == 2 && -n "$2" ]] || usage; resume=$2 ;; + *) usage ;; +esac +release_tools_preflight release +export RUN_EXTERNAL_TESTS=${RUN_EXTERNAL_TESTS:-true} +export RUN_SLOW_TESTS=${RUN_SLOW_TESTS:-true} +export RELEASE_ALL_DEDICATED_EXTERNAL_TESTS=${RELEASE_ALL_DEDICATED_EXTERNAL_TESTS:-false} +if [[ "$mode" != resume ]]; then + for flag_name in RUN_EXTERNAL_TESTS RUN_SLOW_TESTS RELEASE_ALL_DEDICATED_EXTERNAL_TESTS; do + [[ "${!flag_name}" == true || "${!flag_name}" == false ]] || { echo "$flag_name must be true or false" >&2; exit 2; } + done + if [[ "$RELEASE_ALL_DEDICATED_EXTERNAL_TESTS" == true && "$RUN_EXTERNAL_TESTS" == false ]]; then + echo 'Dedicated external tests require RUN_EXTERNAL_TESTS=true' >&2 + exit 2 + fi +fi +gradle=("$RELEASE_ROOT/gradlew" -p "$RELEASE_ROOT" -PreleaseAllMode=true "-PrunExternalTests=$RUN_EXTERNAL_TESTS" "-PrunSlowTests=$RUN_SLOW_TESTS" --configuration-cache-problems=fail) +if [[ -n "${RELEASE_ALL_PLUGIN_DIR:-}" ]]; then + [[ -f "$RELEASE_ALL_PLUGIN_DIR/src/main/groovy/se/alipsa/gradle/plugin/release/ReleaseAllPlanTask.groovy" ]] || { + echo 'Invalid nexus-release-plugin checkout: RELEASE_ALL_PLUGIN_DIR' >&2; exit 1; + } + gradle+=("-PreleaseAllPluginDir=$RELEASE_ALL_PLUGIN_DIR") +fi +if [[ "$mode" == resume ]]; then + "${gradle[@]}" releaseAll "-PreleaseAllResume=$resume" + exit +fi +[[ -z "$(git status --porcelain)" ]] || { echo 'Commit or stash changes before release planning' >&2; exit 1; } +"${gradle[@]}" releaseAllPlan +manifest="$RELEASE_ROOT/build/releaseAll/manifest.json" +selected=$(groovy -e 'println new groovy.json.JsonSlurper().parse(new File(args[0])).selected.size()' "$manifest") +if [[ "$selected" == 0 ]]; then echo 'No eligible unreleased components'; exit; fi +if [[ "$mode" == plan ]]; then + "${gradle[@]}" -PreleaseAllSigningReport=true releaseAllSigningCheck +else + "${gradle[@]}" releaseAllSigningCheck +fi +maven_selected=$(groovy -e 'println new groovy.json.JsonSlurper().parse(new File(args[0])).selected.any { !it.projectPath }' "$manifest") +if [[ "$maven_selected" == true ]]; then + if [[ ! -d "${GNUPGHOME:-$HOME/.gnupg}" ]] || ! command -v gpg >/dev/null || ! gpg --batch --list-secret-keys --with-colons 2>/dev/null | grep '^sec:' >/dev/null; then + echo 'Maven signing requires GPG and an available secret key; configure your existing release-profile GPG settings.' >&2 + [[ "$mode" == plan ]] || exit 1 + fi +fi +requirements=$(groovy "$RELEASE_ROOT/scripts/release-all/external-inventory.groovy" "$manifest" "$RELEASE_ROOT") +while IFS= read -r requirement; do + case "$requirement" in + gsheets) "${gradle[@]}" :matrix-gsheets:releaseAuthPreflight ;; + datasets) + groovy -e '["https://raw.githubusercontent.com/vincentarelbundock/Rdatasets/master/datasets.csv", "https://raw.githubusercontent.com/vincentarelbundock/Rdatasets/master/csv/datasets/mtcars.csv"].each { endpoint -> def c=URI.create(endpoint).toURL().openConnection(); c.connectTimeout=15000; c.readTimeout=15000; try { if(c.responseCode != 200) throw new IOException("Dataset endpoint unavailable: " + endpoint) } finally { c.disconnect() } }' || { + echo 'Dataset tests require reachable Rdatasets endpoints; see docs/releaseAll.md or set RUN_EXTERNAL_TESTS=false.' >&2; exit 1; + } ;; + bigquery) + [[ -n "${GOOGLE_CLOUD_PROJECT:-}" ]] || { echo 'GOOGLE_CLOUD_PROJECT required' >&2; exit 1; } + if ! command -v docker >/dev/null || ! docker info >/dev/null; then echo 'Running Docker daemon required' >&2; exit 1; fi + adc_file=${GOOGLE_APPLICATION_CREDENTIALS:-$HOME/.config/gcloud/application_default_credentials.json} + [[ -f "$adc_file" ]] || { echo 'BigQuery ADC required; see docs/releaseAll.md or set RELEASE_ALL_DEDICATED_EXTERNAL_TESTS=false.' >&2; exit 1; } ;; + esac +done <<< "$requirements" +if [[ "$mode" == plan ]]; then + echo 'Dry run completed; signing configuration will be checked during preparation.' + exit +fi +scope=$(groovy -e 'new groovy.json.JsonSlurper().parse(new File(args[0])).verificationScope.each { println it }' "$manifest") +analysis=(); formatting=(); builds=() +while IFS= read -r module; do + [[ -n "$module" ]] || continue + analysis+=("$module:codenarcMain") + formatting+=("$module:spotlessCheck") + builds+=("$module:build") + [[ "$module" != ':matrix-gsheets' ]] || analysis+=(':matrix-gsheets:codenarcAuthPreflight') + if [[ "$module" == ':matrix-bigquery' && "$RELEASE_ALL_DEDICATED_EXTERNAL_TESTS" == true ]]; then builds+=(':matrix-bigquery:releaseExternalTest'); fi +done <<< "$scope" +if (( ${#builds[@]} > 0 )); then + "${gradle[@]}" --no-configuration-cache "${analysis[@]}" + "${gradle[@]}" --no-configuration-cache "${formatting[@]}" + "${gradle[@]}" --no-configuration-cache "${builds[@]}" -Pheadless=true +fi +"${gradle[@]}" -PreleaseAllPrepare=true releaseAllStage +"${gradle[@]}" releaseAllPrepareMaven +"${gradle[@]}" bundleAll +[[ "$mode" == bundle ]] || "${gradle[@]}" releaseAll diff --git a/req/releaseAll-plan.md b/req/releaseAll-plan.md new file mode 100644 index 000000000..6f0484f1f --- /dev/null +++ b/req/releaseAll-plan.md @@ -0,0 +1,281 @@ +# Release eligible Matrix modules in one Central bundle + +## 1. Approach + +1.1 [x] Add `releaseAll.sh` to discover release candidates, prepare their signed artifacts, assemble one ZIP, and upload it once. Include Gradle modules plus `matrix-bom` and `matrix-all`. + +1.2 [x] Keep existing module release scripts, `release.sh`, and plugin `bundle`/`release` tasks available for separate releases. + +1.3 [x] Put reusable discovery, dependency eligibility, bundling, validation, and deployment support in `../nexus-release-plugin`; keep Matrix-specific candidate registration and orchestration in Matrix's Gradle build and script. Reuse the existing BOM verifier and its helpers instead of copying their staging, property mapping, API testing, or japicmp logic. + +1.4 [x] Use one Maven-layout archive for all selected components. Central supports multiple components in one archive, with a current upload limit of 1 GB. Combined publishing reduces upload operations; file count and storage usage remain separate metrics. [Bundle documentation](https://central.sonatype.org/publish/publish-portal-upload/), [publishing limits](https://central.sonatype.org/publish/maven-central-publishing-limits/) + +## 2. Eligibility and manifest + +2.1 [x] Introduce typed publication and dependency descriptors containing coordinates, packaging, source project or Maven POM, expected artifact files, dependency origin/configuration, and selection/exclusion reasons. Discover Gradle candidates through configured plugin publications; register the two Maven candidates explicitly. Exclude example projects. + +2.2 [x] Add `releaseAllPlan` to produce a JSON manifest and readable report. Read Gradle coordinates from `MavenPublication`; parse Maven candidates statically from `pom.xml`, `bom.xml`, and their properties, including `${project.version}` and BOM-managed dependency versions. Do not run Maven flattening or dependency resolution during planning. Generate Gradle candidate POMs without building their JARs. Skip candidate versions ending in `-SNAPSHOT`. + +2.3 [x] Check each exact version's POM at `https://repo.maven.apache.org/maven2`, and for coordinates absent there also check authenticated Portal publication status. A positive result from either source means already released; selection requires a repository 404 and an explicit Portal not-published result. Use bounded retries for transient failures; unresolved HTTP, authentication, network, or response errors abort discovery. Do not infer availability from latest-version metadata or local caches. Dry-run therefore needs Portal credentials when repository absence must be confirmed, but never signing credentials. + +2.4 [x] Include Matrix project dependency edges from the production compile classpath, including inherited `compileOnly`, `implementation`, and `api` declarations, even when absent from the published POM. Inspect published dependencies, runtime dependencies, BOM imports, and dependency management as well. Use the actual target project's publication version for project edges, never a substituted previous release. Report every edge with its originating configuration or POM element. Exclude test-only build edges unless they appear in the published POM. + +2.5 [x] Skip candidates with SNAPSHOT dependencies or missing Matrix coordinates outside the bundle. Each Matrix eligibility edge must reference an exact version already released or remaining in the bundle. Repeat exclusions to a fixed point so dependants of blocked modules are skipped too. Report direct and cascading reasons; unresolved POM properties or unsupported dependency expressions are planning errors, not assumed valid dependencies. + +2.6 [x] Compare each BOM-managed Matrix version with the final selected module set. For a selected module, require the BOM pin to equal its publication version; a mismatch skips `matrix-bom` and any `matrix-all` depending on it. For an unselected module, accept any exact BOM-pinned version already released, even when its build script has advanced to a SNAPSHOT; report the difference as a warning only. A pin neither already released nor supplied by the selected bundle blocks the BOM through the dependency rules. Reevaluate this rule after cascading exclusions. Do not exclude an independent module solely because an unselected BOM is stale, rewrite versions, or replace pins automatically. + +2.7 [x] Persist selected and excluded coordinates, dependency edges, expected published Maven dependencies, verification scope, HEAD revision, effective module versions, and hashes of release-relevant build/POM inputs. Consume the manifest in a separate preparation invocation so task dependencies can be configured without network access during Gradle configuration. An empty selection exits successfully without tests, signing, or upload. + +2.8 [x] Before preparation, bundling, and upload, require unchanged HEAD, a clean working tree, matching effective versions, and matching recorded input hashes. Reject stale manifests. Keep generated artifacts under ignored output paths; any release-time generated POM or japicmp configuration must not modify tracked files. + +## 3. Artifact preparation and BOM verification + +3.1 [x] Add root aggregation tasks `bundleAll` and `releaseAll`, accepting the manifest and staged artifact repository. Use typed Gradle properties and configuration-cache-compatible inputs. Preserve existing extension setters and single-module task behavior. + +3.2 [x] Register a local file publishing repository for selected Gradle publications, including signing outputs, under `build/releaseAll/staging/`. Keep Maven downloads and installs in a separate throwaway `build/releaseAll/maven-local/` repository. Maven resolves staged Gradle publications through a releases-only `file://` remote; it never writes downloads into staging or uses the user's Maven Local repository. + +3.3 [x] Make `matrix-bom/verifyBomApi.sh` portable on macOS and Linux before making it a release gate. Replace GNU-only `realpath -m` and `find -printf` with shared Groovy/Java path normalization and directory enumeration helpers, using the already-required Groovy CLI. Batch all path-normalization inputs and directory listings available at each workflow phase into one Groovy invocation returning structured JSON or a single structured TSV batch for shell consumers; do not launch a JVM for each path or directory. Collect the repository, root, BOM, home, and protected Maven-cache paths together. Revalidate after filesystem-changing phases in one further batch when needed. Preserve all deletion safeguards: reject dot-dot and symlink components, resolve existing ancestors for nonexistent paths, check protected directories and marker files, and never delete staging. Retain Bash 4+ and explicitly require `rg`; do not require GNU coreutils or findutils. Add a non-mutating shared preflight used by both the verifier and `releaseAll.sh --dry-run`. + +3.3.1 [x] Extend `matrix-bom/verifyBomApi.sh` with a manifest-driven mode that consumes existing staged artifacts through that file remote and uses the separate local cache. Reuse its current path guards, property mapping, `api-it` execution, and japicmp comparison. Retain standalone SNAPSHOT detection and `--modules` behavior. In manifest mode, select exact unreleased release coordinates from the manifest rather than properties ending in `-SNAPSHOT`; validate their presence without republishing or deleting staging. Maintain a shared staging registration/helper for standalone and release workflows. + +3.4 [x] Run the adapted verifier whenever `matrix-bom` or `matrix-all` is selected. Install the matching BOM into the isolated local cache before building `matrix-all`; when an unselected same-version BOM is already released, resolve that exact BOM from Central. Run the existing API integration tests and, when selected core is covered by the BOM, japicmp against `matrixCoreBaselineVersion`. Preserve compatibility findings as reported warnings and infrastructure/test failures as blocking errors. Do not update the baseline. + +3.5 [x] Retain Central-only dependency/plugin resolution plus the staged file remote for verification. Use isolated verification settings without signing. For signed Maven artifact preparation, preserve user/global Maven settings and their GPG configuration while adding the staging repository profile and isolated local-cache argument; do not reuse `verify-settings.xml` as both `-s` and `-gs` for signing. Fail clearly if a configured mirror prevents staged dependency resolution. Keep secrets out of manifests, reports, and generated repository configuration. + +3.6 [x] Prepare Maven candidates in dependency order through the existing release profile's `verify`/`install` lifecycle, never `deploy`, and prove this performs no Central uploads. Copy only the manifest's expected artifact files from build/install outputs into staging, including the signed flattened POM for `matrix-all`. After flattening, require published coordinates and resolved dependency coordinates/scopes/classifiers to match the static manifest expectations; differences abort bundling. + +3.7 [x] Preserve `matrix-all`'s current JAR packaging and publication artifact set through an explicit per-publication policy requiring its POM and main JAR, their signatures, and checksums. Do not introduce sources/Javadoc plugin executions or change packaging. Other JAR publications continue requiring sources and Javadoc JARs; POM packaging requires no JARs. This is a deliberate `matrix-all` compatibility exception, not a claim that Central generally waives these requirements; server validation remains authoritative. [Central requirements](https://central.sonatype.org/publish/requirements/) + +## 4. Bundle, upload, and script + +4.1 [x] Extract shared checksum and validation logic from the plugin. Build the archive from an allow-list of exact manifest coordinates and expected files, not a repository-directory scan or deny-list. Preserve the existing POM/JAR artifact set without introducing Gradle module metadata. Reject missing signatures, invalid POM metadata or coordinates, incorrect MD5/SHA-1 checksums, duplicate archive paths, unexpected components, and bundles over the supported size limit before upload. + +4.2 [x] Make `releaseAll.sh` executable and independent of the caller's working directory. Before discovery, run the shared non-mutating preflight, including in `--dry-run`: verify Java 21, Maven 3.9.9+, Bash 4+, Groovy CLI, Git, and ripgrep (`rg`), and exercise portable path normalization/directory enumeration without creating or deleting files. On macOS, document Homebrew Bash (`brew install bash`) and ripgrep (`brew install ripgrep`) and ensure `/usr/bin/env bash` resolves to Bash 4+, rather than Apple's Bash 3.2. GNU realpath/find are not prerequisites after section 3.3's portability change. Require publishing credentials for Portal checks and signing configuration for preparation; dry-run reports missing signing setup without invoking signing. Check Git cleanliness without running a formatter. After discovery determines the verification scope, run section 4.2.5's external prerequisite preflight before release verification builds, tests, signing, or staging, including in `--dry-run`; only compilation of the dedicated authentication-preflight source set, matrix-gsheets main classes, and their required project dependencies (including matrix-core) is permitted first; the initial tool preflight remains before discovery. Dry-run may therefore compile matrix-core and required dependencies, but never runs verification tests, signs, or stages artifacts. + +4.2.1 [x] `./releaseAll.sh --dry-run`: discover and report eligibility, dependency edges, drift, and verification scope; no signing or upload. + +4.2.2 [x] `./releaseAll.sh --bundle-only`: run verification and create the validated ZIP without uploading. + +4.2.3 [x] `./releaseAll.sh`: verify, prepare, bundle, upload, and check publication. + +4.2.4 [x] `./releaseAll.sh --resume `: resume status/availability checks against the saved manifest and deployment record, without rebuilding or uploading. Permit status-only recovery after source changes, but never reuse that record to authorize a new upload. + +4.2.5 [x] Derive external prerequisites from tests actually tagged `external` and enabled by the final verification scope and effective tag filters, not module names, comments, or profile activation alone. Inspect real class/method annotations and maintain prerequisite mappings for those tests, checked against the tag inventory so additions cannot silently bypass preflight. With external tests enabled, credentialed gsheets tests invoke section 4.2.6's authentication check for the union of scopes requested by the enabled tests, including cleanup, and bounded probes of the corresponding endpoints; enabled datasets external tests probe their dataset endpoints. BOM API verification currently contains no external-tagged tests, so BOM/all-only verification requires neither Google credentials nor dataset endpoint probes. Report each prerequisite's triggering test and fail early with setup instructions or the explicit `RUN_EXTERNAL_TESTS=false` override. Dedicated BigQuery opt-in additionally requires ADC, `GOOGLE_CLOUD_PROJECT`, and a running Docker daemon. Dataset-only checks need no ADC; disabled external tests and status-only `--resume` need no test prerequisites. + +4.2.6 [x] Add an `authPreflight` source set in matrix-gsheets with a small entry-point class exposing `main(String[] args)` and a shared external-test authentication requirements helper. Both the preflight and external-test setup consume this helper's scope requirements for enabled tests, including their cleanup paths. The current union is `GsAuthenticator.SCOPES`, `SCOPE_SHEETS_READONLY`, and `SCOPE_DRIVE_FILE`, deduplicated; probe both Sheets and Drive endpoints plus the existing authentication endpoints. Call `GsAuthenticator.authenticate(requiredScopes)` to reuse its noninteractive scope validation and quota-project handling, including broader Sheets covering Sheets-readonly and broader Drive covering drive.file; do not duplicate that implication logic or accept refresh success alone. Run a Gradle `JavaExec` against the entry point with `authPreflight` output, main output, main runtime dependencies, and explicit Groovy runtime, groovy-json, and project(':matrix-core') dependencies on a dedicated configuration. Explicitly add `platform(libs.groovy.bom)` to `authPreflightImplementation`, `authPreflightRuntimeOnly`, and the dedicated JavaExec dependency configuration so both source-set compile/runtime resolution and JavaExec resolve the versionless Groovy catalog entries; do not assume standard configurations' BOM constraints are inherited. Extend the root `GroovyCompile` task filter for matrix-gsheets to include `compileAuthPreflightGroovy` and apply the same `config/groovy/compileStatic.groovy` script as main code. Include `codenarcAuthPreflight` in static-analysis gates before formatting/tests. Make the test source set consume the shared helper output; never include `authPreflight` classes in the published JAR, sources/Javadoc artifacts, or POM dependencies. Wire source-set compilation and required project outputs explicitly: the preflight also compiles matrix-gsheets, matrix-core, and necessary dependencies before verification builds, including in dry-run. Do not run tests, signing, staging, browser login, or credential writes. Sanitize failures so tokens and credential contents never enter logs or manifests. + +4.2.7 [x] Add offline scope-coverage regression tests executing the actual credential-acquisition paths of `GsUtil.deleteSheet(String)`, `GsUtil.getSheetNames(String)` without supplied credentials, public `GsheetsWriter` write/update operations, and `GsheetsReader.read`, `readAsStrings`, and `readAsObject`, all without supplied credentials. Exercise the corresponding `GsImporter` wrappers and `GsExporter` writer delegation. `DEFAULT_BACKEND` is private and final, so its public injected-backend overload does not affect these callers. Open the Mockito static mock with a forwarding answer that intercepts only `GsAuthenticator.authenticate(List)` to record scopes and throw a sentinel, and delegates every other overload to `Mockito.CALLS_REAL_METHODS`. Do not install a separate `.when(authenticate(anyList()))` stub under `CALLS_REAL_METHODS`: Mockito invokes the real method while installing that stub, causing an unintended credential lookup. The real String overload must delegate to that List interception; assert delegation explicitly. Assert every tested operation reaches the interception with valid inputs and that every recorded scope is covered by the shared helper's union using existing scope implication logic. Stop before credential lookup, API requests, or spreadsheet creation; `getSheetNames` may construct its trusted HTTP transport before authentication, which does not itself make a network call. Close the static mock after each operation; do not add mutable global production backend hooks. + +4.2.8 [x] Maintain a checked inventory of every production call to `GsAuthenticator.authenticate` in matrix-gsheets `src/main`, including statically imported calls and ignoring comments/GroovyDoc. Use Groovy AST inspection with matrix-gsheets' compile classpath and explicitly resolve single-name, aliased, and wildcard static imports from the module AST import tables. Resolve unqualified calls such as `authenticate(SCOPES)` to their imported owner, including `GsAuthenticator.*`; do not rely on the parsed call initially naming `GsAuthenticator`. Respect local method/closure shadowing and fail on unresolved ambiguous authentication targets. Identify call sites by class, enclosing method, and overload/call expression; require each to map to an exercised coverage operation or a documented exclusion. The current service call sites are `GsUtil.deleteSheet`, `GsUtil.getSheetNames`, `GsheetsReader.buildSheetsService`, and `GsheetsWriter.buildSheetsService`; map private builders to their public read/write operations and cover importer/exporter delegation. Exclude only authenticator-internal overload delegation and explicit interactive-authentication internals, which have their own authentication tests. Fail the inventory test on added or changed unmapped calls so a new service scope request cannot silently escape coverage. + +4.3 [x] Enable `RUN_EXTERNAL_TESTS=true` and `RUN_SLOW_TESTS=true` by default for normal build tasks and the BOM verifier; respect explicit `false` overrides and preserve existing tag exclusions. Define the Gradle verification set as selected Gradle modules plus their transitive production project dependants, excluding examples. This includes SNAPSHOT dependants for regression testing but never publishing them. Run scoped `codenarcMain` plus `:matrix-gsheets:codenarcAuthPreflight` when its preflight source set is involved, then `spotlessCheck`, then `build -Pheadless=true`; `build` must include module-specific checks such as `matrix-csv:testNonUtf8DefaultEncoding`. Dedicated external tasks are opt-in through `RELEASE_ALL_DEDICATED_EXTERNAL_TESTS=true` (default `false`) and also require `RUN_EXTERNAL_TESTS=true`; reject contradictory settings during preflight. For BigQuery, add a release-specific external task that includes `external` and excludes `flaky`, leaving the existing `externalTest` task unchanged. Never gate a release on flaky-tagged tests. Dependencies may compile as prerequisites; unrelated modules' tests do not become release gates. The selected BOM/all additionally gates on section 3's verifier, preserving its existing Docker-unavailable emulator skips. Run section 4.2.5's prerequisite checks for the actual enabled external-tagged tests, even when dedicated external tasks are disabled; an external Maven profile alone does not create credential or network requirements. + +4.4 [x] Implement Sonatype's `checkComponentPublished` contract: authenticated `GET /api/v1/publisher/published` with required URL-encoded `namespace`, `name`, and `version` parameters, using the existing Bearer token authentication (Basic is also supported). Parse the success body as `{"published": true|false}` with a required boolean; do not coerce missing or malformed values to false. The documented error responses are 400, 401, 403, and 500: 400/401/403 block immediately; retry 500 transiently within bounds, then block. Other unexpected statuses or transport errors also block. Source: the official OpenAPI at `https://central.sonatype.com/api-doc`, operation `checkComponentPublished`; record its capture date with section 4.4.1's fixtures. Use section 4.4.1's recorded fixtures to establish namespace and absence semantics; never map absence or authorization errors to not-published. [Official API entry point](https://central.sonatype.com/api-doc) + +4.4.1 [ ] Capture authenticated, sanitized published-status fixtures using read-only requests for a known published Matrix coordinate and a coordinate never uploaded. Establish whether `namespace` is the exact Maven groupId or the registered namespace; record the tested parameter values, response statuses/bodies, capture date, and resulting namespace/absence policy alongside the fixtures. Never store credentials or Authorization headers. Confirm the tests in section 5.1 pass with these fixtures and record their commands/results before marking this task complete. This is a prerequisite for discovery that depends on Portal absence, including dry-run, and every real upload; until confirmed, fail closed rather than permitting repository-only absence. + +4.5 [x] Immediately before upload, recheck every selected coordinate against both repository and Portal status, bypassing earlier availability results. If any coordinate is now published, stop and require fresh planning and bundling. Check locally recorded deployments for overlapping coordinates and resume unresolved submissions instead of uploading again. These checks reduce duplicate submissions but cannot guarantee atomicity against another publisher racing the upload. + +4.6 [x] Reuse the Central client and shared deployment polling logic. Upload exactly once with automatic publishing. Persist deployment ID and bundle hash immediately; never automatically retry an upload with an unknown outcome. Resume polls the recorded deployment; failures report affected coordinates. After publication, poll for every selected POM and main JAR, where applicable, on Central, treating propagation delay separately from upload failure. + +4.7 [x] Document prerequisites, credentials, signing, all commands/defaults, verification scope, dependency exclusions, output locations, `matrix-all`'s artifact exception, and recovery from stale manifests, failed deployments, and interrupted uploads. Explain that strict compile-classpath eligibility is intentional: if core is `3.10.0-SNAPSHOT`, a non-SNAPSHOT stats module with `compileOnly project(':matrix-core')` is skipped even when core `3.9.x` exists on Central. The dependency must target core's already-released exact version, or core must join the bundle with a release version; existing separate-release scripts retain their behavior. Show a BOM pin to released arff `0.3.0` remaining valid when unselected arff's build version is `0.3.1-SNAPSHOT`. Document prerequisites from the enabled external-tagged test inventory alongside the opt-in ones: credentialed gsheets tests require ADC validated against the shared union of Sheets, Sheets-readonly, and Drive-file scopes (including cleanup) by the existing noninteractive authenticator, and access to both Sheets and Drive endpoints; document that broader Drive grants satisfy Drive-file through the existing scope checks; datasets external tests require their dataset endpoints; dedicated BigQuery checks additionally require `GOOGLE_CLOUD_PROJECT` and Docker. Explain that the current BOM API tests are offline helpers or separately handled emulator tests, so a BOM/all-only release adds no Google credential or dataset endpoint requirement. Document the dedicated unpublished `authPreflight` entry point/classpath, its explicit Groovy runtime/groovy-json/matrix-core dependencies, and compilation of matrix-core and necessary project dependencies before authentication, including in dry-run. Include credential setup, endpoint-specific network failure messages, and `RUN_EXTERNAL_TESTS=false` instructions. Document dedicated checks as opt-in and unconditional exclusion of flaky-tagged tests from release gates. Do not rewrite versions, update the core baseline, commit, push, or create tags. + +4.8 [ ] Deliver and validate the plugin enhancement first, test Matrix against it locally, then update Matrix's plugin version to the published enhancement. Keep plugin publication as a separate release step. + +## 5. Verification and acceptance + +5.1 [ ] Extend plugin unit and TestKit tests for exact-version lookup, repository 404 with Portal-published status, explicit Portal absence, mixed versions, customized coordinates, empty selections, and dependency exclusion cascades. Include `compileOnly` edges and confirm SNAPSHOT or blocked core excludes stats/ggplot even without those dependencies in their POMs. Use confirmed Portal fixtures for groupId versus registered-namespace semantics and never-uploaded coordinates; cover required query encoding, strict boolean parsing, blocking 400/401/403, bounded retries for 500, unexpected statuses, and network failures. + +5.2 [x] Test static Maven planning with same-version artifacts unavailable from Central and an empty local cache. Cover selected-module BOM pin mismatches blocking BOM/all, unselected module pins to released versions producing only warnings, unavailable unselected pins blocking BOM/all, and reevaluation after cascading exclusions. Include arff `0.3.0` pinned in the BOM with unselected build version `0.3.1-SNAPSHOT`. Cover unresolved properties and flattened POM mismatches after staging. Ensure planning does not invoke Maven flattening or depend on Maven Local. + +5.3 [x] Test combined JAR/POM bundles and the explicit `matrix-all` artifact policy, checksum correctness, missing signatures, duplicate paths, unexpected files, size rejection, and allow-list isolation from third-party downloads/bookkeeping. Verify existing single-module tests still pass. + +5.4 [x] Exercise the adapted BOM verifier with non-SNAPSHOT release candidates, isolated read-only staging, a separate download cache, existing `--modules` and SNAPSHOT workflows, API ITs, japicmp, and signing configuration from user Maven settings. Test on macOS using BSD system utilities and on Linux; GNU realpath/find must not be needed. Cover nonexistent paths, spaces, symlink and dot-dot rejection, protected/ancestor directories, marker enforcement, and dry-run preflight failures before builds or uploads. Assert that all path/listing inputs at a phase are processed in one helper invocation, with no per-path JVM launches. Verify tracked files are unchanged and preparation/`--bundle-only` make no upload requests. + +5.5 Workflow acceptance. Mark each sub-task complete only after its tests pass and the exact commands/results are recorded in this plan or the PR description. + +5.5.1 [x] Upload and resume: use the mock Central server to prove multiple selected modules cause exactly one upload and excluded modules cause none. Cover a coordinate becoming Portal-published just before upload, deployment failure, unknown upload outcome, timeout, resume without re-upload, and delayed Central availability. + +5.5.2 [x] Stale-manifest and scope guards: test changed HEAD, dirty tree, changed effective versions/input hashes, invocation from another directory, scoped regression tests, and unrelated external-test failures not gating release. + +5.5.3 [x] External-test preflight: confirm dedicated tasks are absent from the default graph; opt-in BigQuery checks require ADC/project/Docker; contradictory flags fail early; flaky-tagged tests never enter release gates. Check unavailable endpoints and missing/expired credentials are detected before verification builds, including dry-run, allowing only minimal authentication-preflight compilation. Confirm BOM/all-only scope needs neither ADC nor dataset probes, comments/profile activation do not count as tags, dataset-only scope needs no ADC, disabled external tests bypass probes, and status-only resume needs no test credentials. Check prerequisite mappings against the actual external-tagged inventory so test additions/removals update required capabilities. + +5.5.4 [x] GSheets authentication preflight and classpath: test noninteractive authentication/quota-project handling through its injectable backend without real credentials. Confirm refreshable ADC missing required scopes fails before spreadsheet creation, valid Sheets plus drive.file passes, and broader Drive covers drive.file through existing checks. Verify cleanup scopes and Sheets/Drive probes. Run the real JavaExec task in an isolated fixture without inherited Groovy version constraints and with controlled authentication responses; require explicit BOM resolution and successful loading of Groovy, groovy-json, Logger, and the entry point. Verify matrix-core compilation prerequisites, the static-compilation script and rejection of invalid authenticator signatures, and `codenarcAuthPreflight` participation in `check`/`build` and the earlier analysis gate. Confirm helper/preflight classes and dependencies do not leak into published artifacts or POMs. + +5.5.5 [x] Scope coverage and call-site inventory: confirm tests and preflight consume the shared requirements helper, then execute section 4.2.7's actual production operations under the recording static mock. Assert String-overload delegation reaches the List interception under `CALLS_REAL_METHODS`; include reader/importer/exporter paths and prove no API request occurs. A new uncovered scope must fail coverage. Run section 4.2.8's inventory with fixtures for qualified calls, unqualified single-name imports, aliased imports, and wildcard imports, including a future unqualified call in a `GsAuthUtils`-style fixture. Detect added/changed unmapped production calls; ignore comments and unrelated same-name methods/closures. Resolve fixtures against the module compile classpath and verify the existing unqualified writer call is present. Record the exact commands/results for these coverage and inventory tests independently. + +5.6 [x] Run configuration-cache TestKit tests twice with `--configuration-cache --configuration-cache-problems=fail`, overriding Matrix's warning default; require reuse without problems. Cover discovery/preparation task graphs separately. + +5.7 [x] Record successful implementation checks before marking tasks complete: plugin `./gradlew build`; Matrix `./gradlew codenarcMain :matrix-gsheets:codenarcAuthPreflight`, `./gradlew spotlessCheck`, then `./gradlew build -Pheadless=true` (which includes the full normal test suite and module checks). Add `bash -n releaseAll.sh matrix-bom/verifyBomApi.sh` and ShellCheck. Record the manifest-scoped release checks and adapted verifier command/results in the release report, including external/slow test overrides and any existing skips. + +**Chosen defaults:** include both Maven artifacts; retain slow/external flags for normal builds and BOM verification; dedicated external tasks are opt-in and flaky tests never gate release; use batched portable macOS/Linux verifier helpers with Bash 4+ and explicit ripgrep preflight; derive credential/network preflight from actually enabled external-tagged tests; validate the shared union of gsheets external-test scopes, including Drive cleanup, through its existing noninteractive authenticator; use a statically compiled unpublished entry point/classpath with explicit Groovy BOM, Groovy, groovy-json, and matrix-core dependencies, permitting required main compilation before verification builds and in dry-run; current BOM/all-only verification needs no ADC or dataset endpoint probes; skip blocked modules and their dependants under strict compile-time eligibility, including modules depending on SNAPSHOT core; fail closed on uncertain publication status; preserve the existing `matrix-all` artifact set; block BOM/all on selected-module pin mismatches while allowing released pins for unselected modules with warnings. Implementation verification and remaining operational gates are recorded below. + + +## 6. Implementation verification record (2026-10-09) + +6.1 [x] Plugin implementation and existing separate-release regression tests passed: +`cd ../nexus-release-plugin && ./gradlew build --no-configuration-cache --max-workers=2`. +The combined unit tests cover dependency cascades, old and mismatched BOM pins, strict Portal responses, +allow-listed archives, source guards, exactly one upload for multiple components, a late-published +component, failed deployments, unknown outcomes and propagation/resume. TestKit ran planning, +bundling, preparation and the source guard twice with +`--configuration-cache --configuration-cache-problems=fail`, and asserted reuse. Static Maven paths +are preserved from task inputs, including fixture POMs outside Matrix's directory naming convention. +The preparation cache fixture selects both Maven components and checks their four install commands +over two invocations; real signed Maven preparation is recorded in 6.10. Concurrent local +submissions additionally reserve one journal atomically and produce only one upload. + +6.2 [x] Matrix analysis, formatting and normal build passed in the required order: +`./gradlew codenarcMain :matrix-gsheets:codenarcAuthPreflight --no-configuration-cache --max-workers=3`; +`./gradlew spotlessCheck --no-configuration-cache --max-workers=3`; +`MAVEN_OPTS="-Dmaven.repo.local=$PWD/build/releaseAll/bom-verify" ./gradlew build -Pheadless=true --no-configuration-cache --max-workers=3 "-Dmaven.repo.local=$PWD/build/releaseAll/bom-verify"`. +The isolated repository was populated by 6.4, avoiding a stale matrix-pict snapshot in the user's Maven Local. +A pre-existing CodeNarc failure in `PlotWithoutJavafxTest` was fixed by loading the class through +its classloader; the test still checks that JavaFX is unavailable. +Then `./gradlew test :matrix-gsheets:jar :matrix-gsheets:sourcesJar :matrix-gsheets:javadocJar :matrix-gsheets:generatePomFileForMavenPublication -Pheadless=true --no-configuration-cache --max-workers=3` +passed. No external credentials or flaky suites were enabled during implementation verification. + +6.3 [x] Portable helpers and script orchestration passed: +`groovy scripts/release-all/test-tools.groovy .` (39 assertions); +`groovy scripts/release-all/test-workflow.groovy .`; +`bash -n releaseAll.sh matrix-bom/verifyBomApi.sh scripts/release-all/preflight.sh`; +`shellcheck releaseAll.sh matrix-bom/verifyBomApi.sh scripts/release-all/preflight.sh`. +Coverage includes batched paths with spaces/missing ancestors, symlinks/dot-dot, +namespace-preserving Maven settings with existing signing properties and owner-only permissions, +released-BOM pins, real external annotations versus comments, Java/Groovy tags, aliases, +disabled/flaky/slow filtering, unmapped methods, empty selection, caller directory, scoped build order, +bundle-only, one submission invocation, resume with dirty sources and contradictory flags. +The macOS/Linux portable-tools CI workflow passed; remote results are recorded in 6.14. + +6.4 [x] Real macOS portable BOM verification passed in both modes: +`RUN_EXTERNAL_TESTS=false RUN_SLOW_TESTS=false BOM_VERIFY_REPO="$PWD/build/releaseAll/bom-verify" bash matrix-bom/verifyBomApi.sh`; +`groovy scripts/release-all/test-manifest-verifier.groovy . build/releaseAll/bom-verify`. +The manifest fixture verifies 20 unreleased non-SNAPSHOT coordinates with API ITs and japicmp, +read-only staging, separate download cache, no Gradle republishing, no deploy goal and unchanged +staging hashes. Existing Docker-unavailable emulator skips and japicmp compatibility warnings +remain unchanged. Linux integration passed in 6.14; actual signed Maven preparation passed in 6.10. + +6.5 [x] GSheets runtime, scope and publication checks passed: +`./gradlew :matrix-gsheets:test --tests '*AuthPreflightTest' --tests '*AuthenticationInventoryTest' --tests '*ReleaseScopeCoverageTest' :matrix-gsheets:releaseAuthPreflight --args=--check-classpath --no-configuration-cache --max-workers=3` +passed six tests and the real JavaExec. +The full build includes these tests too. Authentication/probe responses are mocked; no real Sheets +are created. Main, sources and Javadoc JAR ZIP entries were inspected and contain no preflight/helper +classes; the generated POM contains no preflight, matrix-core or groovy-json runtime dependency. +Independent JavaExec classpath and controlled-authentication acceptance also passed in 6.11. + +6.6 [x] Actual Matrix release wiring passed twice: +`./gradlew help -PreleaseAllMode=true -PreleaseAllPluginDir=../nexus-release-plugin --configuration-cache --configuration-cache-problems=fail --max-workers=3`. +The second invocation reused the cache. Verification builds intentionally disable configuration +cache for existing test listeners; discovery, preparation/guard and bundling have strict TestKit reuse. + +6.7 [ ] Capture live Portal fixtures (4.4.1). No Sonatype credentials are available in this environment. +`groovy scripts/release-all/capture-portal-fixture.groovy` fails safely with setup instructions. +Mock responses are deliberately not committed as purported live fixtures. Absence-based discovery +and every real upload remain blocked until authenticated read-only capture confirms the contract. +This also leaves the live-fixture portion of 5.1 incomplete. + +6.8 [ ] Publish the enhanced plugin separately and update Matrix's published plugin version (4.8). +The implementation uses the explicitly selected sibling composite; ordinary Matrix builds retain +the existing plugin version. No plugin/Matrix publication, Git tag or version rewrite has occurred. + +6.9 [x] Linux verifier integration acceptance passed in 6.14. Local checks run on macOS; the remote workflow verifies both OSes and both BOM modes on Linux. + + +6.10 [x] Actual signed Maven preparation passed: +`groovy scripts/release-all/test-maven-preparation.groovy . ../nexus-release-plugin`. +This isolated fixture uses a disposable GPG key and its own settings/repositories, never the user's +keyring or Maven Local. It runs the real preparation and bundle tasks, installs a same-version BOM, +resolves a staged library, builds/signs an aggregate JAR, validates its flattened dependency POM, +verifies every staged signature with GPG, preserves the input settings and removes temporary signing +settings. It checks no deploy goal occurs and the fixture Git tree remains clean. +The check exposed and fixed private-method closure dispatch, GString ProcessBuilder arguments and +missing console forwarding of subprocess output. Nonempty Maven preparation in TestKit now covers +these paths as well as strict cache reuse. GPG was installed to perform this check. + +6.11 [x] Independent source-set/runtime acceptance passed: +`groovy scripts/release-all/test-auth-classpath.groovy .`. +The fixture copies the actual unpublished helper/entry point, excludes existing Groovy JARs, adds +versionless Groovy/groovy-json with explicit BOM constraints to its own configurations and runs +JavaExec outside Matrix's shared build. It loads main/core/JSON classes, exercises the same mocked +scope/authentication/Sheets-and-Drive probe checks in a separate Java process, and confirms static +compilation rejects an invalid authenticator signature. No real credentials or API requests are used. + +6.12 [x] Extended early-failure orchestration acceptance passed: +`groovy scripts/release-all/test-workflow.groovy .`. +Authentication failures in dry-run/bundle-only stop before analysis/build/staging; dataset endpoint +failure requires no Sheets preflight; dedicated BigQuery is absent by default and missing project, +Docker or explicit ADC-file prerequisites block opt-in. The authentication and network responses in +this shell fixture are controlled; real entry-point behavior is verified in 6.5 and 6.11. + +6.13 [x] The actual clean-checkout command `./releaseAll.sh --dry-run` passed after implementation commits. +It generated Gradle POMs and the manifest/report, found all current publications to be SNAPSHOTs, +reported an empty selection and exited without tests, signing, staging, credentials or uploads. +The report exposed Maven test dependencies that flattening omits; those are now excluded from +Maven eligibility, with a TestKit regression proving a test-only SNAPSHOT does not block an aggregate. + + +6.14 [x] Remote CI passed on the implementation commit: +[Matrix workflow](https://github.com/Alipsa/matrix/actions/runs/37967268614) +ran `groovy scripts/release-all/test-tools.groovy .` and +`groovy scripts/release-all/test-workflow.groovy .` on macOS and Linux, plus +`BOM_VERIFY_REPO="$GITHUB_WORKSPACE/build/releaseAll/bom-verify" bash matrix-bom/verifyBomApi.sh` +and `groovy scripts/release-all/test-manifest-verifier.groovy . build/releaseAll/bom-verify` +on Linux with `RUN_EXTERNAL_TESTS=false RUN_SLOW_TESTS=false`. +Both verifier modes completed API ITs/japicmp; manifest mode confirmed 20 unreleased fixture +coordinates, immutable staging and separate cache. The +[plugin workflow](https://github.com/Alipsa/nexus-release-plugin/actions/runs/37967189167) +passed `./gradlew build` and Gradle 8.13/9.4 compatibility jobs. + +6.15 [x] Explicit standalone module selection and repository safeguards passed locally. +`module_properties=$(groovy matrix-bom/BomSnapshots.groovy matrix-bom/bom.xml | cut -d= -f1 | paste -sd, -)`; +`RUN_EXTERNAL_TESTS=false RUN_SLOW_TESTS=false BOM_VERIFY_REPO="$PWD/build/releaseAll/bom-verify" bash matrix-bom/verifyBomApi.sh --modules "$module_properties"`. +This republished the explicit current SNAPSHOT list into the isolated marked repository and ran +API ITs/japicmp successfully. `groovy scripts/release-all/test-tools.groovy .` now runs 39 assertions, +including the verifier's actual guard functions extracted without any deletion/build code: +project/BOM/home/Maven-cache roots and project ancestors reject; an unmarked existing directory +rejects; a marked fixture directory passes; project-looking, symlink and dot-dot directories reject. + +6.16 [x] PRs opened after local implementation verification: +[Matrix #476](https://github.com/Alipsa/matrix/pull/476) and +[release plugin #6](https://github.com/Alipsa/nexus-release-plugin/pull/6). +Both use `feature/release-all`; no commits or pushes were made to main. +Only operational fixture capture and the separate published-plugin release/version switch remain +unchecked. Mock fixture tests cannot substitute for credentialed live Portal capture. + +7. PR #476 review fixes + +7.1 [x] Import `GsAuthenticator` in `GsUtilTest`; exercise both external entry points +through offline authentication interception. Passed +`./gradlew :matrix-gsheets:codenarcMain :matrix-gsheets:codenarcAuthPreflight --no-configuration-cache --max-workers=2`, +`./gradlew :matrix-gsheets:spotlessApply :matrix-gsheets:spotlessCheck :matrix-gsheets:test --no-configuration-cache --max-workers=2` +(161 tests), and `./gradlew test -Pheadless=true --no-configuration-cache --max-workers=3`. + +7.2 [x] In manifest mode, validate matching BOM modules as a subset of staging; +keep standalone exact-set validation. Place generated settings in a fresh temporary +directory and remove them on exit. Passed +`groovy scripts/release-all/test-manifest-verifier.groovy . build/releaseAll/bom-verify`: +unpinned matrix-smile, selected newer arff with an older unselected BOM pin, +read-only staging, API ITs/japicmp, and a cache inside matrix-bom without modifying +its source settings. + +7.3 [x] Canonicalize read-only checkout/home/Maven paths while rejecting symlinks in +write/delete targets; include the offending path in errors. Document exact verifier +tools. Inherit outer-class tags and Disabled annotations in both source languages. +Passed `groovy scripts/release-all/test-tools.groovy .` (46 assertions), +`groovy scripts/release-all/test-workflow.groovy .`, +`bash -n releaseAll.sh matrix-bom/verifyBomApi.sh scripts/release-all/preflight.sh`, +and `shellcheck releaseAll.sh matrix-bom/verifyBomApi.sh scripts/release-all/preflight.sh`. + +7.4 [x] Supply the authentication inventory classpath through a lazy JVM argument +provider. A guarded offline `help` invocation rejects any compile-classpath resolution +and passed. `./gradlew :matrix-gsheets:test --configuration-cache --configuration-cache-problems=fail --max-workers=2` +passed twice, storing and reusing the configuration cache. + +7.5 [x] Re-run the standalone verifier and isolated authentication classpath acceptance, +including the persistent guarded-help regression. Passed +`RUN_EXTERNAL_TESTS=false RUN_SLOW_TESTS=false BOM_VERIFY_REPO="$PWD/build/releaseAll/bom-verify" bash matrix-bom/verifyBomApi.sh` +and `groovy scripts/release-all/test-auth-classpath.groovy .`. + +7.6 [x] Push the verified fixes (`c5bd394f`) and update PR #476 with the successful +commands above. CI [run 37993222681](https://github.com/Alipsa/matrix/actions/runs/37993222681) +was queued when the PR was updated; local verification is complete. diff --git a/scripts/release-all/Paths.groovy b/scripts/release-all/Paths.groovy new file mode 100644 index 000000000..944d30d0b --- /dev/null +++ b/scripts/release-all/Paths.groovy @@ -0,0 +1,68 @@ +#!/usr/bin/env groovy +import groovy.json.JsonOutput +import java.nio.file.Files +import java.nio.file.Path + +// One invocation handles every path/listing in a workflow phase; no GNU utilities. +// Only writable targets reject links; protected read-only paths are canonicalized. +String configuredCount = System.getenv('RELEASE_PATHS_STRICT_COUNT') +int strictCount = configuredCount == null ? + (System.getenv('RELEASE_PATHS_RELEASE') == 'true' ? 2 : args.length) : configuredCount.toInteger() +int index = 0 +def result = args.collect { String input -> + boolean strict = index++ < strictCount + Path path = Path.of(input).toAbsolutePath() + if (path.any { it.toString() == '..' }) throw new IllegalArgumentException("Path contains dot-dot: ${input}") + Path cursor = path + List missing = [] + while (!Files.exists(cursor)) { + if (Files.isSymbolicLink(cursor)) throw new IllegalArgumentException("Symlink path component: ${cursor} (input: ${input})") + missing.add(0, cursor.fileName.toString()) + cursor = cursor.parent + } + if (missing && !Files.isDirectory(cursor)) throw new IllegalArgumentException("Existing path ancestor is not a directory: ${input}") + Path check = cursor + while (check != null) { + if (strict && Files.isSymbolicLink(check)) throw new IllegalArgumentException("Symlink path component: ${check} (input: ${input})") + check = check.parent + } + Path canonical = cursor.toRealPath() + missing.each { canonical = canonical.resolve(it) } + List entries = [] + List directories = [] + if (Files.isDirectory(canonical)) { + Files.list(canonical).withCloseable { stream -> + stream.forEach { child -> + entries.add(child.fileName.toString()) + if (Files.isDirectory(child)) directories.add(child.fileName.toString()) + } + } + } + [path: canonical.toString(), entries: entries.sort(), directories: directories.sort()] +} +if (System.getenv('RELEASE_PATHS_RELEASE') == 'true') { + if (result.size() != 6 || !args.take(2).every { Path.of(it).isAbsolute() }) { + throw new IllegalArgumentException('Release staging and cache must be absolute paths') + } + List repositories = result.take(2).collect { Path.of(it.path) } + List protectedPaths = result.drop(2).collect { Path.of(it.path) } + repositories.each { Path repository -> + if (repository.parent == null || protectedPaths.any { it == repository || it.startsWith(repository) } || repository.startsWith(protectedPaths.last())) { + throw new IllegalArgumentException('Release repository overlaps protected project/home/Maven cache paths') + } + } + if (repositories[0].startsWith(repositories[1]) || repositories[1].startsWith(repositories[0])) { + throw new IllegalArgumentException('Release staging and download cache must be separate') + } +} +if (System.getenv('RELEASE_PATHS_TSV') == 'true') { + result.each { println([it.path, it.entries.join(','), it.directories.join(',')].join('|')) } +} else if (System.getenv('RELEASE_PATHS_LINES') == 'true') { + result.each { println it.path } +} else if (System.getenv('RELEASE_PATHS_LIST') == 'true') { + result.each { it.entries.each { println it } } +} else if (System.getenv('RELEASE_PATHS_DIRS') == 'true') { + result.each { it.directories.each { println it } } +} else { + println JsonOutput.toJson(result) +} diff --git a/scripts/release-all/capture-portal-fixture.groovy b/scripts/release-all/capture-portal-fixture.groovy new file mode 100644 index 000000000..2b84f5794 --- /dev/null +++ b/scripts/release-all/capture-portal-fixture.groovy @@ -0,0 +1,28 @@ +#!/usr/bin/env groovy +import groovy.json.JsonOutput +import groovy.json.JsonSlurper +import java.net.http.* +import java.time.Duration + +Properties properties = new Properties() +File configuration = new File(System.getProperty('user.home'), '.gradle/gradle.properties') +if (configuration.exists()) configuration.withInputStream { properties.load(it) } +String user = System.getenv('SONATYPE_USERNAME') ?: properties.getProperty('sonatypeUsername') +String password = System.getenv('SONATYPE_PASSWORD') ?: properties.getProperty('sonatypePassword') +if (!user || !password) throw new IllegalStateException('Set SONATYPE_USERNAME/SONATYPE_PASSWORD or Gradle sonatypeUsername/sonatypePassword') +String namespace = 'se.alipsa.matrix' +def client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(15)).build() +def request = { String name, String version -> + String query = [namespace: namespace, name: name, version: version].collect { k, v -> "${k}=${URLEncoder.encode(v, 'UTF-8')}" }.join('&') + def response = client.send(HttpRequest.newBuilder(URI.create('https://central.sonatype.com/api/v1/publisher/published?' + query)) + .timeout(Duration.ofSeconds(30)).header('Authorization', 'Bearer ' + Base64.encoder.encodeToString("${user}:${password}".getBytes('UTF-8'))).GET().build(), HttpResponse.BodyHandlers.ofString()) + if (response.statusCode() != 200) throw new IllegalStateException("Fixture request returned HTTP ${response.statusCode()}") + def body = new JsonSlurper().parseText(response.body()) + if (!(body.published instanceof Boolean)) throw new IllegalStateException('Fixture response missing boolean published') + [parameters: [namespace: namespace, name: name, version: version], status: response.statusCode(), body: [published: body.published]] +} +def fixture = [source: 'https://central.sonatype.com/api-doc#checkComponentPublished', captured: java.time.Instant.now().toString(), + namespacePolicy: 'groupId', published: request('matrix-core', '3.9.0'), absent: request('matrix-core', '0.0.0-release-all-never-uploaded')] +if (fixture.published.body.published != true || fixture.absent.body.published != false) throw new IllegalStateException('Portal semantics not confirmed; discovery remains blocked') +new File(args ? args[0] : 'req/releaseAll-portal-fixture.json').text = JsonOutput.prettyPrint(JsonOutput.toJson(fixture)) +println 'Confirmed exact Maven groupId namespace and published:false for never-uploaded coordinate; saved sanitized fixture.' diff --git a/scripts/release-all/external-inventory.groovy b/scripts/release-all/external-inventory.groovy new file mode 100644 index 000000000..450406230 --- /dev/null +++ b/scripts/release-all/external-inventory.groovy @@ -0,0 +1,242 @@ +#!/usr/bin/env groovy +import com.sun.source.tree.ClassTree +import com.sun.source.tree.LiteralTree +import com.sun.source.tree.MethodTree +import com.sun.source.util.TreeScanner +import groovy.json.JsonSlurper +import org.codehaus.groovy.ast.expr.ConstantExpression +import org.codehaus.groovy.control.SourceUnit +import javax.tools.ToolProvider + +// Inspect real annotations and effective filters, not comments or profile activation. +def manifest = new JsonSlurper().parse(new File(args[0])) +boolean external = System.getenv('RUN_EXTERNAL_TESTS') == 'true' +boolean dedicated = System.getenv('RELEASE_ALL_DEDICATED_EXTERNAL_TESTS') == 'true' +boolean slow = System.getenv('RUN_SLOW_TESTS') != 'false' +if (dedicated && !external) throw new IllegalStateException('Dedicated external tests require RUN_EXTERNAL_TESTS=true') +if (!external) return +File root = new File(args[1]) +Map mappings = new JsonSlurper().parse(new File(root, 'scripts/release-all/external-requirements.json')) as Map +Set requirements = [] +Set modules = manifest.verificationScope as Set +if (manifest.selected?.any { !it.projectPath }) modules.add(':matrix-bom') +Set testAnnotations = ['org.junit.jupiter.api.Test', 'org.junit.jupiter.api.RepeatedTest', + 'org.junit.jupiter.api.TestFactory', 'org.junit.jupiter.api.TestTemplate', 'org.junit.jupiter.params.ParameterizedTest'] +Closure excluded = { Set tags -> tags.contains('flaky') || (!slow && tags.contains('slow')) } +Closure register = { String site, String module -> + Map mapped = mappings[site] + if (mapped == null || mapped.module != module) throw new IllegalStateException("Unmapped external-tagged test: ${site}; update external-requirements.json") + requirements.addAll(mapped.requirements as List) + System.err.println("External prerequisite triggered by ${site}") +} +Closure parseGroovy = { File source -> + def unit = SourceUnit.create(source.name, source.text) + unit.parse(); unit.completePhase(); unit.nextPhase(); unit.convert() + unit +} +Closure parseJava = { File source, Closure inspect -> + ToolProvider.systemJavaCompiler.getStandardFileManager(null, null, null).withCloseable { manager -> + def task = ToolProvider.systemJavaCompiler.getTask(null, manager, null, ['-proc:none'], null, manager.getJavaFileObjects(source)) + task.parse().each(inspect) + } +} +// Index local annotation definitions before scanning their usages. Unknown composed +// annotations and test-source inheritance must not silently hide JUnit tags. +Map localAnnotations = [:] +Map sourceClasses = [:] +Closure groovyOwner = { unit, annotation -> + String name = annotation.classNode.name + if (name.contains('.')) return name + unit.AST.imports.find { it.alias == name }?.type?.name ?: + (name in ['Override', 'Deprecated', 'SuppressWarnings'] ? 'java.lang.' + name : + (unit.AST.starImports.any { it.packageName == 'org.junit.jupiter.api.' } ? 'org.junit.jupiter.api.' + name : name)) +} +Closure javaOwner = { tree, annotation -> + String name = annotation.annotationType.toString() + if (name.contains('.')) return name + if (name in ['Override', 'Deprecated', 'SuppressWarnings']) return 'java.lang.' + name + def named = tree.imports.find { it.qualifiedIdentifier.toString().tokenize('.').last() == name } + named?.qualifiedIdentifier?.toString() ?: (tree.imports.any { it.qualifiedIdentifier.toString() == 'org.junit.jupiter.api.*' } ? 'org.junit.jupiter.api.' + name : name) +} +modules.each { String module -> + if (module == ':matrix-bigquery' && !dedicated) return + File sources = new File(root, module.substring(1) + '/src/test') + if (!sources.exists()) return + sources.eachFileRecurse { File source -> + if (!(source.name.endsWith('.groovy') || source.name.endsWith('.java'))) return + if (source.name.endsWith('.java')) { + parseJava(source) { tree -> + Closure owner = { annotation -> javaOwner(tree, annotation) } + tree.accept(new TreeScanner() { + @Override + Void visitClass(ClassTree cls, String enclosing) { + String name = enclosing ? "${enclosing}.${cls.simpleName}" : [tree.packageName, cls.simpleName].findAll { it }.join('.') + sourceClasses["${module}|${name}".toString()] = [name: name, module: module, + packageName: tree.packageName?.toString() ?: '', superclass: cls.extendsClause?.toString(), + interfaces: cls.implementsClause.collect { it.toString() }, + annotations: cls.modifiers.annotations, owner: owner, + imports: tree.imports.collect { it.qualifiedIdentifier.toString() }] + super.visitClass(cls, name) + } + }, null) + } + return + } + def unit = parseGroovy(source) + unit.AST.classes.each { cls -> + String name = cls.name.replace('$', '.') + sourceClasses["${module}|${name}".toString()] = [name: name, module: module, + packageName: unit.AST.packageName?.replaceFirst(/\.$/, '') ?: '', superclass: cls.superClass?.name, + interfaces: cls.interfaces.collect { it.name }, + annotations: cls.annotations, owner: { annotation -> groovyOwner(unit, annotation) }, + imports: unit.AST.imports.collect { it.type.name }, + aliases: unit.AST.imports.collectEntries { [(it.alias): it.type.name] }, + starImports: unit.AST.starImports.collect { it.packageName + '*' }] + } + unit.AST.classes.findAll { it.isAnnotationDefinition() }.each { cls -> + localAnnotations[cls.name] = [annotations: cls.annotations, owner: { annotation -> groovyOwner(unit, annotation) }] + } + } +} +Closure> composedTags +composedTags = { String name, Set visiting -> + Map definition = localAnnotations[name] + if (definition == null) { + if (name.startsWith('org.junit.jupiter.') || name.startsWith('java.lang.') || name.startsWith('groovy.') || + name in ['Override', 'Deprecated', 'SuppressWarnings', 'org.testcontainers.junit.jupiter.Testcontainers', 'org.testcontainers.junit.jupiter.Container']) return [] as Set + throw new IllegalStateException("Cannot resolve test annotation ${name}; use explicit JUnit tags or a local Groovy annotation") + } + if (name in visiting) throw new IllegalStateException("Cyclic test annotation ${name}") + Set result = [] + definition.annotations.each { annotation -> + String owner = definition.owner(annotation) + if (owner == 'org.junit.jupiter.api.Tag') { + if (!(annotation.getMember('value') instanceof ConstantExpression)) throw new IllegalStateException("Resolve nonliteral test tag in ${name} explicitly") + result.add(annotation.getMember('value').text) + } else if (owner == 'org.junit.jupiter.api.Tags') { + throw new IllegalStateException("Map container test tags in ${name} explicitly") + } else { + result.addAll(composedTags(owner, visiting + name)) + } + } + result +} +// Resolve within the module, respecting explicit imports, packages and enclosing +// classes. Never match a library type against an unrelated simple name. +Closure resolveInheritedType = { Map cls, String inheritedName -> + if (cls == null) return null + String name = inheritedName?.replaceAll(/<.*>/, '')?.replace('$', '.') + if (!name) return null + String first = name.tokenize('.').first() + String imported = cls.aliases?.get(first) ?: cls.imports.find { it.tokenize('.').last() == first } + List candidates = [] + if (imported) { + candidates.add(imported + name.substring(first.length())) + } else if (name.contains('.') && Character.isLowerCase(name.charAt(0))) { + candidates.add(name) + } else { + String enclosing = cls.name + while (enclosing.contains('.')) { + enclosing = enclosing.substring(0, enclosing.lastIndexOf('.')) + if (enclosing == cls.packageName) break + candidates.add(enclosing + '.' + name) + } + candidates.add(cls.packageName ? cls.packageName + '.' + name : name) + (cls.starImports ?: cls.imports.findAll { it.endsWith('.*') }).each { candidates.add(it.substring(0, it.length() - 1) + name) } + } + candidates.collect { sourceClasses["${cls.module}|${it}".toString()] }.find { it != null } +} +Closure> parents = { Map cls -> + if (cls == null) return [] + ([cls.superclass] + cls.interfaces).findAll { it }.collect { resolveInheritedType(cls, it) }.findAll { it != null } +} +Closure checkInheritance = { String site, String module -> + Map cls = sourceClasses["${module}|${site.replace('$', '.')}".toString()] + Set completed = [] + Closure visit + visit = { Map ancestor, Set path -> + if (ancestor.name in path) throw new IllegalStateException("Cyclic test-source inheritance for ${site}") + if (ancestor.name in completed) return + boolean affectsTags = ancestor.annotations.any { annotation -> + String name = ancestor.owner(annotation) + name in ['org.junit.jupiter.api.Tag', 'org.junit.jupiter.api.Tags', 'org.junit.jupiter.api.Disabled'] || + !composedTags(name, [] as Set).empty + } + if (affectsTags) { + throw new IllegalStateException("Cannot resolve inherited test tags for ${site} from ${ancestor.name}; declare the effective tags and disabled state explicitly on a test without tagged test-source inheritance") + } + parents(ancestor).each { visit(it, path + ancestor.name) } + completed.add(ancestor.name) + } + parents(cls).each { visit(it, [cls.name] as Set) } +} +modules.each { String module -> + if (module == ':matrix-bigquery' && !dedicated) return + File sources = new File(root, module.substring(1) + '/src/test') + if (!sources.exists()) return + sources.eachFileRecurse { File source -> + if (source.name.endsWith('.java')) { + parseJava(source) { tree -> + Closure owner = { annotation -> javaOwner(tree, annotation) } + Closure> tags = { annotations -> + if (annotations.any { owner(it) == 'org.junit.jupiter.api.Tags' }) throw new IllegalStateException('Map container test tags explicitly') + Set indirect = annotations.findAll { !(owner(it) in ['org.junit.jupiter.api.Tag', 'org.junit.jupiter.api.Tags']) }.collectMany { composedTags(owner(it), [] as Set) }.toSet() + indirect + (annotations.findAll { owner(it) == 'org.junit.jupiter.api.Tag' }.collect { annotation -> + def value = annotation.arguments.first() + if (!(value instanceof LiteralTree)) throw new IllegalStateException('Resolve nonliteral Java test tags explicitly') + value.value.toString() + } as Set) + } + tree.accept(new TreeScanner() { + @Override + Void visitClass(ClassTree cls, Map parent) { + Map context = [name: parent ? "${parent.name}\$${cls.simpleName}" : [tree.packageName, cls.simpleName].findAll { it }.join('.'), tags: (parent?.tags ?: [] as Set) + tags(cls.modifiers.annotations), + disabled: parent?.disabled || cls.modifiers.annotations.any { owner(it) == 'org.junit.jupiter.api.Disabled' }] + checkInheritance(context.name.toString(), module) + super.visitClass(cls, context) + } + @Override + Void visitMethod(MethodTree method, Map context) { + Set effective = context.tags + tags(method.modifiers.annotations) + if (!context.disabled && !method.modifiers.annotations.any { owner(it) == 'org.junit.jupiter.api.Disabled' } && + method.modifiers.annotations.any { owner(it) in testAnnotations } && effective.contains('external') && !excluded(effective)) { + register("${context.name}#${method.name}".toString(), module) + } + super.visitMethod(method, context) + } + }, null) + } + return + } + if (!source.name.endsWith('.groovy')) return + def unit = parseGroovy(source) + Closure owner = { annotation -> groovyOwner(unit, annotation) } + Closure> tags = { annotations -> + if (annotations.any { owner(it) == 'org.junit.jupiter.api.Tags' }) throw new IllegalStateException('Map container test tags explicitly') + Set indirect = annotations.findAll { !(owner(it) in ['org.junit.jupiter.api.Tag', 'org.junit.jupiter.api.Tags']) }.collectMany { composedTags(owner(it), [] as Set) }.toSet() + indirect + (annotations.findAll { owner(it) == 'org.junit.jupiter.api.Tag' }.collect { annotation -> + if (!(annotation.getMember('value') instanceof ConstantExpression)) throw new IllegalStateException('Resolve nonliteral external-test tags explicitly') + annotation.getMember('value').text + } as Set) + } + unit.AST.classes.each { cls -> + checkInheritance(cls.name, module) + List ancestors = [] + def enclosing = cls + while (enclosing != null) { + ancestors.add(enclosing) + enclosing = enclosing.outerClass + } + Set classTags = ancestors.collectMany { tags(it.annotations) }.toSet() + if (excluded(classTags) || ancestors.any { ancestor -> ancestor.annotations.any { owner(it) == 'org.junit.jupiter.api.Disabled' } }) return + cls.methods.each { method -> + Set effective = classTags + tags(method.annotations) + if (method.annotations.any { owner(it) in testAnnotations } && effective.contains('external') && !excluded(effective) && + !method.annotations.any { owner(it) == 'org.junit.jupiter.api.Disabled' }) { + register("${cls.name}#${method.name}".toString(), module) + } + } + } + } +} +requirements.sort().each { println it } diff --git a/scripts/release-all/external-requirements.json b/scripts/release-all/external-requirements.json new file mode 100644 index 000000000..394a48587 --- /dev/null +++ b/scripts/release-all/external-requirements.json @@ -0,0 +1,278 @@ +{ + "datasets.DatasetTest#testFromUrl": { + "module": ":matrix-datasets", + "requirements": [ + "datasets" + ] + }, + "datasets.RdatasetsTest#fetchInfo": { + "module": ":matrix-datasets", + "requirements": [ + "datasets" + ] + }, + "datasets.RdatasetsTest#testFetchData": { + "module": ":matrix-datasets", + "requirements": [ + "datasets" + ] + }, + "datasets.RdatasetsTest#testFetchDataKeepsMatrixName": { + "module": ":matrix-datasets", + "requirements": [ + "datasets" + ] + }, + "datasets.RdatasetsTest#testFetchDataSingleArgOverload": { + "module": ":matrix-datasets", + "requirements": [ + "datasets" + ] + }, + "datasets.RdatasetsTest#testFetchDataWithNonExistentDataset": { + "module": ":matrix-datasets", + "requirements": [ + "datasets" + ] + }, + "datasets.RdatasetsTest#testFetchInfoHtmlAndPlainText": { + "module": ":matrix-datasets", + "requirements": [ + "datasets" + ] + }, + "datasets.RdatasetsTest#testFetchInfoWithNonExistentDataset": { + "module": ":matrix-datasets", + "requirements": [ + "datasets" + ] + }, + "datasets.RdatasetsTest#testOverview": { + "module": ":matrix-datasets", + "requirements": [ + "datasets" + ] + }, + "datasets.RdatasetsTest#testOverviewReturnsIndependentCopy": { + "module": ":matrix-datasets", + "requirements": [ + "datasets" + ] + }, + "datasets.RdatasetsTest#testRefresh": { + "module": ":matrix-datasets", + "requirements": [ + "datasets" + ] + }, + "datasets.RdatasetsTest#testSearch": { + "module": ":matrix-datasets", + "requirements": [ + "datasets" + ] + }, + "datasets.RdatasetsTest#testSearchWithNoResults": { + "module": ":matrix-datasets", + "requirements": [ + "datasets" + ] + }, + "test.alipsa.matrix.bigquery.BqDataTypesTest#testBigDecimal": { + "module": ":matrix-bigquery", + "requirements": [ + "bigquery" + ] + }, + "test.alipsa.matrix.bigquery.BqDataTypesTest#testBigInteger": { + "module": ":matrix-bigquery", + "requirements": [ + "bigquery" + ] + }, + "test.alipsa.matrix.bigquery.BqDataTypesTest#testBoolean": { + "module": ":matrix-bigquery", + "requirements": [ + "bigquery" + ] + }, + "test.alipsa.matrix.bigquery.BqDataTypesTest#testByte": { + "module": ":matrix-bigquery", + "requirements": [ + "bigquery" + ] + }, + "test.alipsa.matrix.bigquery.BqDataTypesTest#testByteArray": { + "module": ":matrix-bigquery", + "requirements": [ + "bigquery" + ] + }, + "test.alipsa.matrix.bigquery.BqDataTypesTest#testComplexData": { + "module": ":matrix-bigquery", + "requirements": [ + "bigquery" + ] + }, + "test.alipsa.matrix.bigquery.BqDataTypesTest#testDouble": { + "module": ":matrix-bigquery", + "requirements": [ + "bigquery" + ] + }, + "test.alipsa.matrix.bigquery.BqDataTypesTest#testFloat": { + "module": ":matrix-bigquery", + "requirements": [ + "bigquery" + ] + }, + "test.alipsa.matrix.bigquery.BqDataTypesTest#testInstant": { + "module": ":matrix-bigquery", + "requirements": [ + "bigquery" + ] + }, + "test.alipsa.matrix.bigquery.BqDataTypesTest#testInteger": { + "module": ":matrix-bigquery", + "requirements": [ + "bigquery" + ] + }, + "test.alipsa.matrix.bigquery.BqDataTypesTest#testLegacyDate": { + "module": ":matrix-bigquery", + "requirements": [ + "bigquery" + ] + }, + "test.alipsa.matrix.bigquery.BqDataTypesTest#testLegacyTime": { + "module": ":matrix-bigquery", + "requirements": [ + "bigquery" + ] + }, + "test.alipsa.matrix.bigquery.BqDataTypesTest#testLocalDate": { + "module": ":matrix-bigquery", + "requirements": [ + "bigquery" + ] + }, + "test.alipsa.matrix.bigquery.BqDataTypesTest#testLocalDateTime": { + "module": ":matrix-bigquery", + "requirements": [ + "bigquery" + ] + }, + "test.alipsa.matrix.bigquery.BqDataTypesTest#testLocalTime": { + "module": ":matrix-bigquery", + "requirements": [ + "bigquery" + ] + }, + "test.alipsa.matrix.bigquery.BqDataTypesTest#testLong": { + "module": ":matrix-bigquery", + "requirements": [ + "bigquery" + ] + }, + "test.alipsa.matrix.bigquery.BqDataTypesTest#testShort": { + "module": ":matrix-bigquery", + "requirements": [ + "bigquery" + ] + }, + "test.alipsa.matrix.bigquery.BqDataTypesTest#testString": { + "module": ":matrix-bigquery", + "requirements": [ + "bigquery" + ] + }, + "test.alipsa.matrix.bigquery.BqDataTypesTest#testTypeMapping": { + "module": ":matrix-bigquery", + "requirements": [ + "bigquery" + ] + }, + "test.alipsa.matrix.bigquery.BqDataTypesTest#testZonedDateTime": { + "module": ":matrix-bigquery", + "requirements": [ + "bigquery" + ] + }, + "test.alipsa.matrix.bigquery.BqTest#testCreateAndRetrieve": { + "module": ":matrix-bigquery", + "requirements": [ + "bigquery" + ] + }, + "test.alipsa.matrix.bigquery.BqTest#testCreateAndRetrieveAirquality": { + "module": ":matrix-bigquery", + "requirements": [ + "bigquery" + ] + }, + "test.alipsa.matrix.bigquery.BqTest#testExample": { + "module": ":matrix-bigquery", + "requirements": [ + "bigquery" + ] + }, + "test.alipsa.matrix.bigquery.BqTest#testListProjects": { + "module": ":matrix-bigquery", + "requirements": [ + "bigquery" + ] + }, + "test.alipsa.matrix.bigquery.BqTest#testListTableInfo": { + "module": ":matrix-bigquery", + "requirements": [ + "bigquery" + ] + }, + "test.alipsa.matrix.gsheets.GsTest#testExport": { + "module": ":matrix-gsheets", + "requirements": [ + "gsheets" + ] + }, + "test.alipsa.matrix.gsheets.GsTest#testExportImport": { + "module": ":matrix-gsheets", + "requirements": [ + "gsheets" + ] + }, + "test.alipsa.matrix.gsheets.GsTest#testExportWithSpaceInMatrixName": { + "module": ":matrix-gsheets", + "requirements": [ + "gsheets" + ] + }, + "test.alipsa.matrix.gsheets.GsTest#testImportAsObjectWithNull": { + "module": ":matrix-gsheets", + "requirements": [ + "gsheets" + ] + }, + "test.alipsa.matrix.gsheets.GsTest#testImportWithNull": { + "module": ":matrix-gsheets", + "requirements": [ + "gsheets" + ] + }, + "test.alipsa.matrix.gsheets.GsTest#testUpdatePreservesDecimalScale": { + "module": ":matrix-gsheets", + "requirements": [ + "gsheets" + ] + }, + "test.alipsa.matrix.gsheets.GsUtilTest#testGetSheetNames": { + "module": ":matrix-gsheets", + "requirements": [ + "gsheets" + ] + }, + "test.alipsa.matrix.gsheets.GsUtilTest#testGetSheetNamesWithMultipleSheets": { + "module": ":matrix-gsheets", + "requirements": [ + "gsheets" + ] + } +} \ No newline at end of file diff --git a/scripts/release-all/manifest-bom.groovy b/scripts/release-all/manifest-bom.groovy new file mode 100644 index 000000000..a45b8235c --- /dev/null +++ b/scripts/release-all/manifest-bom.groovy @@ -0,0 +1,25 @@ +#!/usr/bin/env groovy +import groovy.json.JsonSlurper +import groovy.xml.XmlSlurper + +def manifest = new JsonSlurper().parse(new File(args[0])) +def pom = new XmlSlurper().parse(new File(args[1])) +def pins = pom.dependencyManagement.dependencies.dependency.collectEntries { [it.artifactId.text(), it.version.text()] } +def properties = pom.properties.children().collectEntries { [it.name().toString(), it.text()] } +boolean bomSelected = manifest.selected.any { it.artifactId == 'matrix-bom' } +println "__bom_selected=${bomSelected}" +manifest.selected.findAll { it.projectPath && pins[it.artifactId] }.each { component -> + String expression = pins[component.artifactId] + String property = expression.startsWith('${') ? expression.substring(2, expression.length() - 1) : null + if (!property) throw new IllegalStateException('BOM pin must reference its version property') + if (properties[property] != component.version) { + if (bomSelected) throw new IllegalStateException('Selected module differs from BOM pin') + // An already-released BOM can retain an older pin; resolve that exact pin from Central. + return + } + File directory = new File(args[2], "${component.groupId.replace('.', '/')}/${component.artifactId}/${component.version}") + component.files.each { String name -> + if (!new File(directory, name).isFile()) throw new IllegalStateException("Missing staged artifact: ${name}") + } + println "${property}=${component.version}" +} diff --git a/scripts/release-all/preflight.sh b/scripts/release-all/preflight.sh new file mode 100644 index 000000000..81fe0e01a --- /dev/null +++ b/scripts/release-all/preflight.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Shared tool and portable filesystem preflight; does not create or remove files. +release_tools_preflight() { + (( BASH_VERSINFO[0] >= 4 )) || { echo 'Use Bash 4+ (macOS: brew install bash)' >&2; return 1; } + local tool + for tool in java mvn groovy grep; do + command -v "$tool" >/dev/null || { echo "Required tool missing: $tool" >&2; return 1; } + done + if [[ "${1:-release}" == release ]]; then + command -v git >/dev/null || { echo 'Required tool missing: git' >&2; return 1; } + java -version 2>&1 | grep -Eq 'version "21([."]|$)' || { echo 'Java 21 is required' >&2; return 1; } + else + local java_version + java_version=$(java -version 2>&1) + if [[ ! "$java_version" =~ version\ \"([0-9]+) ]]; then + echo 'Java 21+ is required' >&2; return 1 + fi + (( BASH_REMATCH[1] >= 21 )) || { echo 'Java 21+ is required' >&2; return 1; } + fi + local maven_version + maven_version=$(mvn --version) + maven_version=${maven_version%%$'\n'*} + [[ "$maven_version" =~ Apache\ Maven\ ([0-9]+)\.([0-9]+)\.([0-9]+) ]] || return 1 + (( BASH_REMATCH[1] > 3 || (BASH_REMATCH[1] == 3 && (BASH_REMATCH[2] > 9 || (BASH_REMATCH[2] == 9 && BASH_REMATCH[3] >= 9))) )) || { + echo 'Maven 3.9.9+ is required' >&2; return 1; + } + RELEASE_PATHS_STRICT_COUNT=0 groovy "$RELEASE_ROOT/scripts/release-all/Paths.groovy" "$RELEASE_ROOT" "$RELEASE_ROOT/matrix-bom" "$HOME" "$HOME/.m2/repository" >/dev/null +} diff --git a/scripts/release-all/staged-settings.groovy b/scripts/release-all/staged-settings.groovy new file mode 100644 index 000000000..b2af702d3 --- /dev/null +++ b/scripts/release-all/staged-settings.groovy @@ -0,0 +1,40 @@ +#!/usr/bin/env groovy +import groovy.xml.XmlParser +import groovy.xml.XmlUtil +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.nio.file.attribute.PosixFilePermissions + +// Shared by isolated verification and signing: preserve input settings and add a file remote. +if (args.size() < 3 || args.size() > 4) throw new IllegalArgumentException('usage: source-settings staging output-settings [repository-id=release-staging]') +File input = new File(args[0]) +File output = new File(args[2]) +String repositoryId = args.size() == 4 ? args[3] : 'release-staging' +File temporary +try { + // Namespace-unaware parsing preserves default xmlns while allowing ordinary child names. + Node settings = input.exists() ? new XmlParser(false, false).parse(input) : new Node(null, 'settings') + Node profiles = settings.children().find { it instanceof Node && it.name().toString().tokenize(':').last() == 'profiles' } as Node + if (profiles == null) profiles = settings.appendNode('profiles') + if (profiles.children().any { it instanceof Node && it.children().any { child -> child instanceof Node && child.name().toString().tokenize(':').last() == 'id' && child.text() == repositoryId } }) { + throw new IllegalArgumentException('Reserved staging profile already exists in input settings') + } + Node profile = profiles.appendNode('profile') + profile.appendNode('id', repositoryId) + Node repository = profile.appendNode('repositories').appendNode('repository') + repository.appendNode('id', repositoryId) + repository.appendNode('url', new File(args[1]).toURI().toString()) + repository.appendNode('releases').appendNode('enabled', 'true') + repository.appendNode('snapshots').appendNode('enabled', 'false') + Node active = settings.children().find { it instanceof Node && it.name().toString().tokenize(':').last() == 'activeProfiles' } as Node + if (active == null) active = settings.appendNode('activeProfiles') + active.appendNode('activeProfile', repositoryId) + output.parentFile.mkdirs() + temporary = Files.createTempFile(output.parentFile.toPath(), 'maven-settings-', '.xml', PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString('rw-------'))).toFile() + temporary.setText(XmlUtil.serialize(settings), 'UTF-8') + Files.move(temporary.toPath(), output.toPath(), StandardCopyOption.REPLACE_EXISTING) +} catch (Exception ignored) { + throw new IllegalStateException('Cannot prepare isolated Maven settings; check the source XML, reserved staging profile and writable output directory') +} finally { + temporary?.delete() +} diff --git a/scripts/release-all/test-auth-classpath.groovy b/scripts/release-all/test-auth-classpath.groovy new file mode 100644 index 000000000..a31d21295 --- /dev/null +++ b/scripts/release-all/test-auth-classpath.groovy @@ -0,0 +1,104 @@ +#!/usr/bin/env groovy +import groovy.json.JsonOutput +import groovy.json.JsonSlurper +import java.nio.file.Files + +// Independent Gradle source-set fixture: no root build's Groovy platform inheritance. +File root = new File(args ? args[0] : '.').canonicalFile +File output = new File(root, 'build/releaseAll'); output.mkdirs() +File fixture = Files.createTempDirectory(output.toPath(), 'auth-classpath-').toFile() +File log = new File(output, 'auth-classpath-test.log'); log.text = '' +def run = { List command, File directory, boolean succeeds = true -> + Process process = new ProcessBuilder(command).directory(directory).redirectErrorStream(true).start() + String text = process.inputStream.text + log.append(text) + int status = process.waitFor() + assert succeeds ? status == 0 : status != 0: "Unexpected fixture result; inspect ${log}" + text +} +try { + File lazyGuard = new File(fixture, 'lazy-classpath.init.gradle') + lazyGuard.text = """gradle.beforeProject { module -> + if (module.name == 'matrix-gsheets') { + module.configurations.configureEach { configuration -> + if (configuration.name == 'compileClasspath') { + configuration.incoming.beforeResolve { + throw new GradleException('GSheets compileClasspath resolved during help') + } + } + } + } +}""" + run([new File(root, 'gradlew').path, 'help', '-I', lazyGuard.path, '--no-configuration-cache', '--offline'], root) + File init = new File(fixture, 'classpath.init.gradle') + File classpathFile = new File(fixture, 'classpath.json') + init.text = """allprojects { module -> + if (module.name == 'matrix-gsheets') { + module.afterEvaluate { + tasks.register('writeReleaseAuthClasspath') { + dependsOn tasks.named('classes'), tasks.named('authPreflightClasses'), tasks.named('testClasses') + doLast { new File(${JsonOutput.toJson(classpathFile.path)}).text = groovy.json.JsonOutput.toJson([runtime: sourceSets.authPreflight.runtimeClasspath.files.collect { it.absolutePath }, tests: sourceSets.test.runtimeClasspath.files.collect { it.absolutePath }, agent: configurations.mockitoAgent.singleFile.absolutePath]) } + } + } + } +}""" + run([new File(root, 'gradlew').path, ':matrix-gsheets:writeReleaseAuthClasspath', '-I', init.path, '--no-configuration-cache', '--max-workers=3'], root) + Map resolved = new JsonSlurper().parse(classpathFile) as Map + List classpath = (resolved.runtime + resolved.tests).unique() as List + classpath = classpath.findAll { !new File(it).name.startsWith('groovy') && !it.contains('/authPreflight') } + File source = new File(fixture, 'src/authPreflight/groovy/se/alipsa/matrix/gsheets/preflight'); source.mkdirs() + ['AuthPreflight.groovy', 'ExternalAuthRequirements.groovy'].each { name -> + new File(source, name).bytes = new File(root, 'matrix-gsheets/src/authPreflight/groovy/se/alipsa/matrix/gsheets/preflight/' + name).bytes + } + new File(source, 'ControlledPreflight.groovy').text = '''package se.alipsa.matrix.gsheets.preflight +import test.alipsa.matrix.gsheets.AuthPreflightTest +class ControlledPreflight { + static void main(String[] args) { + def checks = new AuthPreflightTest() + checks.currentUnionIncludesCleanupAndAcceptsBroaderGrants() + checks.entryPointUsesSharedScopesAndProbesSheetsAndDrive() + checks.entryPointSanitizesCredentialFailures() + } +} +''' + new File(fixture, 'compileStatic.groovy').bytes = new File(root, 'config/groovy/compileStatic.groovy').bytes + new File(fixture, 'settings.gradle').text = "rootProject.name='isolated-auth-preflight'" + new File(fixture, 'build.gradle').text = """plugins { id 'groovy' } +repositories { mavenCentral() } +sourceSets { authPreflight } +configurations { authPreflightExecution } +dependencies { + authPreflightImplementation platform('org.apache.groovy:groovy-bom:5.1.1') + authPreflightRuntimeOnly platform('org.apache.groovy:groovy-bom:5.1.1') + authPreflightImplementation 'org.apache.groovy:groovy' + authPreflightImplementation 'org.apache.groovy:groovy-json' + authPreflightImplementation files(${JsonOutput.toJson(classpath)}) + authPreflightExecution platform('org.apache.groovy:groovy-bom:5.1.1') + authPreflightExecution 'org.apache.groovy:groovy' + authPreflightExecution 'org.apache.groovy:groovy-json' +} +tasks.withType(GroovyCompile).configureEach { groovyOptions.configurationScript = file('compileStatic.groovy') } +tasks.register('releaseAuthPreflight', JavaExec) { + dependsOn tasks.named('authPreflightClasses') + classpath = sourceSets.authPreflight.runtimeClasspath + configurations.authPreflightExecution + mainClass = 'se.alipsa.matrix.gsheets.preflight.AuthPreflight' + args '--check-classpath' +} +tasks.register('controlledAuthPreflight', JavaExec) { + dependsOn tasks.named('authPreflightClasses') + classpath = sourceSets.authPreflight.runtimeClasspath + configurations.authPreflightExecution + mainClass = 'se.alipsa.matrix.gsheets.preflight.ControlledPreflight' + jvmArgs '-javaagent:' + ${JsonOutput.toJson(resolved.agent)} +} +""" + String success = run([new File(root, 'gradlew').path, '-p', fixture.path, 'releaseAuthPreflight', 'controlledAuthPreflight', '--no-configuration-cache', '--max-workers=3'], fixture) + assert success.contains('Authentication preflight runtime classpath resolved') + assert success.contains('Sheets external test authentication and network preflight passed') + File entry = new File(source, 'AuthPreflight.groovy') + entry.text = entry.text.replace('GsAuthenticator.authenticate(ExternalAuthRequirements.scopes())', 'GsAuthenticator.authenticate(ExternalAuthRequirements.scopes(), 12345)') + String failure = run([new File(root, 'gradlew').path, '-p', fixture.path, 'compileAuthPreflightGroovy', '--no-configuration-cache', '--max-workers=3'], fixture, false) + assert failure.contains('Static type checking') && failure.contains('authenticate') + println "Isolated authentication JavaExec passed with explicit Groovy BOM/runtime, JSON, main/core classes and controlled authentication/probe responses; static compilation rejected an invalid authenticator signature. Log: ${log}" +} finally { + fixture.deleteDir() +} diff --git a/scripts/release-all/test-inventory-inheritance.groovy b/scripts/release-all/test-inventory-inheritance.groovy new file mode 100644 index 000000000..de94759db --- /dev/null +++ b/scripts/release-all/test-inventory-inheritance.groovy @@ -0,0 +1,96 @@ +#!/usr/bin/env groovy +import groovy.json.JsonOutput +import java.nio.file.Files + +File root = new File(args ? args[0] : '.').canonicalFile +File temporary = Files.createTempDirectory('matrix-inventory-inheritance').toFile() +try { + File mapping = new File(temporary, 'scripts/release-all/external-requirements.json') + mapping.parentFile.mkdirs() + mapping.text = '{}' + File manifest = new File(temporary, 'manifest.json') + manifest.text = JsonOutput.toJson([verificationScope: [':fixture']]) + Closure scan = { File sourceRoot, boolean slow -> + def builder = new ProcessBuilder('groovy', new File(root, 'scripts/release-all/external-inventory.groovy').path, + manifest.path, sourceRoot.path).redirectErrorStream(true) + builder.environment().putAll([RUN_EXTERNAL_TESTS: 'true', RUN_SLOW_TESTS: slow.toString(), + RELEASE_ALL_DEDICATED_EXTERNAL_TESTS: 'true']) + Process process = builder.start() + String output = process.inputStream.text + [code: process.waitFor(), output: output] + } + ['groovy', 'java'].each { language -> + File sources = new File(temporary, "fixture/src/test/${language}") + sources.mkdirs() + File base = new File(sources, "Base.${language}") + File child = new File(sources, "Child.${language}") + File unrelated = new File(sources, "Other.${language}") + base.text = 'package fixture; class Base {}' + child.text = 'package fixture; class Child extends Base { @org.junit.jupiter.api.Test void test() {} }' + def result = scan(temporary, true) + assert result.code == 0: result.output + + // Follow a grandparent even when the intermediate class has no annotations. + unrelated.text = 'package fixture; @org.junit.jupiter.api.Tag("external") class Ancestor {}' + base.text = 'package fixture; class Base extends Ancestor {}' + result = scan(temporary, true) + assert result.code != 0 && result.output.contains('inherited test tags') && result.output.contains('fixture.Ancestor'): result.output + + unrelated.delete() + base.text = 'package fixture; @org.junit.jupiter.api.Disabled class Base {}' + result = scan(temporary, true) + assert result.code != 0 && result.output.contains('inherited test tags'): result.output + + // A tagged class in a different package cannot turn a library base into a local one. + base.text = 'package unrelated; @org.junit.jupiter.api.Tag("external") class ArrayList {}' + child.text = 'package fixture; import java.util.ArrayList; class Child extends ArrayList {}' + result = scan(temporary, true) + assert result.code == 0: result.output + child.text = 'package fixture; class Child extends java.util.ArrayList {}' + result = scan(temporary, true) + assert result.code == 0: result.output + + // A composed tag on a superclass must also trigger the diagnostic. + base.text = 'package fixture; @testutil.Slow class Base {}' + child.text = 'package fixture; class Child extends Base {}' + File annotation = new File(temporary, 'fixture/src/test/groovy/Slow.groovy') + annotation.parentFile.mkdirs() + annotation.text = 'package testutil; @org.junit.jupiter.api.Tag("slow") @interface Slow {}' + result = scan(temporary, false) + assert result.code != 0 && result.output.contains('inherited test tags'): result.output + annotation.delete() + + // JUnit inherits interface tags too, including generic interfaces and parent interfaces. + base.text = 'package fixture; @org.junit.jupiter.api.Tag("external") interface Ext {}' + child.text = 'package fixture; class Child implements Ext { @org.junit.jupiter.api.Test void test() {} }' + result = scan(temporary, true) + assert result.code != 0 && result.output.contains('inherited test tags') && result.output.contains('fixture.Ext'): result.output + + base.text = 'package fixture; interface Ext extends Left, Right {}' + unrelated.text = 'package fixture; interface Left {}\ninterface Right extends Tagged {}\n@org.junit.jupiter.api.Tag("external") interface Tagged {}' + result = scan(temporary, true) + assert result.code != 0 && result.output.contains('inherited test tags') && result.output.contains('fixture.Tagged'): result.output + + // Reaching an untagged interface via both sides of a diamond is not a cycle. + unrelated.text = 'package fixture; interface Left extends Shared {}\ninterface Right extends Shared {}\ninterface Shared {}' + result = scan(temporary, true) + assert result.code == 0: result.output + + unrelated.text = 'package fixture; interface Left extends Right {}\ninterface Right extends Left {}' + result = scan(temporary, true) + assert result.code != 0 && result.output.contains('Cyclic test-source inheritance'): result.output + unrelated.delete() + sources.deleteDir() + } + // Exercise the actual repository, including untagged test bases and nested helpers. + List modules = root.listFiles().findAll { it.directory && it.name.startsWith('matrix-') && it.name != 'matrix-examples' }.collect { ':' + it.name } + assert !modules.empty + manifest.text = JsonOutput.toJson([verificationScope: modules]) + [true, false].each { slow -> + def result = scan(root, slow) + assert result.code == 0: result.output + } + println 'Inheritance inventory checks passed: untagged helpers, ancestor tags, disabled bases, generic Java bases, qualified/imported library names, composed tags, interface trees/diamonds/cycles and all repository modules with slow tests on/off.' +} finally { + temporary.deleteDir() +} diff --git a/scripts/release-all/test-manifest-verifier.groovy b/scripts/release-all/test-manifest-verifier.groovy new file mode 100644 index 000000000..6b9cc3863 --- /dev/null +++ b/scripts/release-all/test-manifest-verifier.groovy @@ -0,0 +1,142 @@ +#!/usr/bin/env groovy +import groovy.json.JsonOutput +import groovy.xml.XmlSlurper +import java.nio.file.Files +import java.nio.file.attribute.PosixFilePermissions +import java.security.MessageDigest + +// Real API/japicmp acceptance against unreleased release-coordinate fixtures, never a real deployment. +File root = new File(args ? args[0] : '.').canonicalFile +File warm = new File(args.size() > 1 ? args[1] : 'build/releaseAll/bom-verify').canonicalFile +File output = new File(root, 'build/releaseAll'); output.mkdirs() +File fixture = Files.createTempDirectory(output.toPath(), 'manifest-verifier-').toFile() +File staging = new File(fixture, 'staging with spaces') +def copyTree = { File source, File target -> + Files.walk(source.toPath()).withCloseable { stream -> + stream.forEach { path -> + File destination = new File(target, source.toPath().relativize(path).toString()) + if (Files.isDirectory(path)) destination.mkdirs() + else { destination.parentFile.mkdirs(); Files.copy(path, destination.toPath()) } + } + } +} +def fingerprint = { File directory -> + Map values = [:] + directory.eachFileRecurse { File file -> + if (file.isFile()) { + MessageDigest digest = MessageDigest.getInstance('SHA-256') + file.withInputStream { input -> + byte[] buffer = new byte[65536] + int count + while ((count = input.read(buffer)) != -1) digest.update(buffer, 0, count) + } + values[directory.toPath().relativize(file.toPath()).toString()] = digest.digest().encodeHex().toString() + } + } + values +} +try { + File bom = new File(fixture, 'matrix-bom'); bom.mkdirs() + ['bom.xml', 'pom.xml', 'verify-settings.xml', 'verifyBomApi.sh', 'BomSnapshots.groovy', 'version-plugin-rules.xml'].each { String name -> + String text = new File(root, 'matrix-bom/' + name).text + new File(bom, name).text = name.endsWith('.xml') ? text.replace('-SNAPSHOT', '-release-all-fixture') : text + } + copyTree(new File(root, 'matrix-bom/src'), new File(bom, 'src')) + new File(bom, 'japicmp').mkdirs() + new File(bom, 'japicmp/pom.xml.template').bytes = new File(root, 'matrix-bom/japicmp/pom.xml.template').bytes + copyTree(new File(root, 'scripts/release-all'), new File(fixture, 'scripts/release-all')) + new File(fixture, 'settings.gradle').bytes = new File(root, 'settings.gradle').bytes + def original = new XmlSlurper().parse(new File(root, 'matrix-bom/bom.xml')) + def properties = original.properties.children().collectEntries { [it.name().toString(), it.text()] } + List components = [] + original.dependencyManagement.dependencies.dependency.each { dependency -> + String artifact = dependency.artifactId.text() + String expression = dependency.version.text() + String version = properties[expression.substring(2, expression.length() - 1)] + if (!version.endsWith('-SNAPSHOT')) return + String release = version.replace('-SNAPSHOT', '-release-all-fixture') + File source = new File(warm, "se/alipsa/matrix/${artifact}/${version}") + assert source.isDirectory(): "Warm isolated artifact missing: ${artifact}:${version}; run verifyBomApi.sh first" + File destination = new File(staging, "se/alipsa/matrix/${artifact}/${release}"); destination.mkdirs() + List files = [] + source.listFiles().findAll { it.name.endsWith('.pom') || it.name.endsWith('.jar') }.each { File artifactFile -> + String name = artifactFile.name.replace('-SNAPSHOT', '-release-all-fixture') + File target = new File(destination, name) + if (name.endsWith('.pom')) target.text = artifactFile.text.replace('-SNAPSHOT', '-release-all-fixture') + else target.bytes = artifactFile.bytes + files.add(name) + } + components.add([projectPath: ':' + artifact, groupId: dependency.groupId.text(), artifactId: artifact, version: release, files: files]) + } + // Selected GPL module is intentionally absent from the BOM; its staged output must be allowed. + Map unpinned = [projectPath: ':matrix-smile', groupId: 'se.alipsa.matrix', artifactId: 'matrix-smile', + version: '1.0.0-fixture', files: ['matrix-smile-1.0.0-fixture.pom', 'matrix-smile-1.0.0-fixture.jar']] + File extra = new File(staging, 'se/alipsa/matrix/matrix-smile/1.0.0-fixture'); extra.mkdirs() + new File(extra, unpinned.files[0]).text = '4.0.0se.alipsa.matrixmatrix-smile1.0.0-fixture' + new File(extra, unpinned.files[1]).bytes = new byte[0] + components.add(unpinned) + def fixtureBom = new XmlSlurper().parse(new File(bom, 'bom.xml')) + components.add([groupId: fixtureBom.groupId.text(), artifactId: 'matrix-bom', version: fixtureBom.version.text(), files: []]) + File manifest = new File(fixture, 'manifest.json'); manifest.text = JsonOutput.toJson([selected: components]) + String originalSettings = new File(bom, 'verify-settings.xml').text + File cache = new File(bom, 'cache') + copyTree(warm, cache) + // This is a new fixture copy, never the user's Maven Local or the warm repository. + File baseline = new File(fixture, 'baseline') + File baselineSource = new File(warm, 'se/alipsa/matrix/matrix-core/' + properties.matrixCoreBaselineVersion) + if (baselineSource.exists()) copyTree(baselineSource, baseline) + new File(cache, 'se/alipsa/matrix').deleteDir() + if (baseline.exists()) copyTree(baseline, new File(cache, 'se/alipsa/matrix/matrix-core/' + properties.matrixCoreBaselineVersion)) + Map before = fingerprint(staging) + Files.walk(staging.toPath()).withCloseable { stream -> + stream.forEach { path -> Files.setPosixFilePermissions(path, PosixFilePermissions.fromString(Files.isDirectory(path) ? 'r-x------' : 'r--------')) } + } + def builder = new ProcessBuilder(['bash', new File(bom, 'verifyBomApi.sh').path, '--manifest', manifest.path, '--staging', staging.path, '--local', cache.path]) + .directory(fixture).redirectErrorStream(true) + builder.environment().putAll([RUN_EXTERNAL_TESTS: 'false', RUN_SLOW_TESTS: 'false']) + Process process = builder.start() + File log = new File(output, 'manifest-verifier-test.log') + log.withOutputStream { process.inputStream.transferTo(it) } + assert process.waitFor() == 0: "Manifest verifier failed; inspect ${log}" + assert new File(bom, 'verify-settings.xml').text == originalSettings: 'Tracked settings were overwritten' + assert fingerprint(staging) == before: 'Maven modified the read-only staged artifacts' + assert log.text.contains('no Gradle publishing') + assert !log.text.contains('maven-deploy-plugin') + assert log.text.contains('japicmp: comparing') + Map arff = components.find { it.artifactId == 'matrix-arff' } + String pinnedVersion = arff.version + String newerVersion = pinnedVersion + '-newer' + Files.walk(staging.toPath()).withCloseable { stream -> + stream.filter { Files.isDirectory(it) }.forEach { Files.setPosixFilePermissions(it, PosixFilePermissions.fromString('rwx------')) } + } + File newer = new File(staging, "se/alipsa/matrix/matrix-arff/${newerVersion}"); newer.mkdirs() + arff.files = arff.files.collect { String name -> + String renamed = name.replace(pinnedVersion, newerVersion) + File source = new File(staging, "se/alipsa/matrix/matrix-arff/${pinnedVersion}/${name}") + File target = new File(newer, renamed) + if (renamed.endsWith('.pom')) target.text = source.text.replace(pinnedVersion, newerVersion) + else target.bytes = source.bytes + renamed + } + arff.version = newerVersion + manifest.text = JsonOutput.toJson([selected: components.findAll { it.artifactId != 'matrix-bom' }]) + before = fingerprint(staging) + Files.walk(staging.toPath()).withCloseable { stream -> + stream.forEach { path -> Files.setPosixFilePermissions(path, PosixFilePermissions.fromString(Files.isDirectory(path) ? 'r-x------' : 'r--------')) } + } + process = builder.start() + File staleLog = new File(output, 'manifest-verifier-stale-pin-test.log') + staleLog.withOutputStream { process.inputStream.transferTo(it) } + assert process.waitFor() == 0: "Stale-pin verifier failed; inspect ${staleLog}" + assert new File(bom, 'verify-settings.xml').text == originalSettings + assert fingerprint(staging) == before + assert staleLog.text.contains('not installing the source BOM') + println "Manifest-mode verifier passed for ${components.size() - 1} unreleased release-coordinate fixtures, read-only staging, separate cache, API tests and japicmp. Log: ${log}" +} finally { + if (staging.exists()) { + Files.walk(staging.toPath()).withCloseable { stream -> + stream.filter { Files.isDirectory(it) }.forEach { Files.setPosixFilePermissions(it, PosixFilePermissions.fromString('rwx------')) } + } + } + fixture.deleteDir() +} diff --git a/scripts/release-all/test-maven-preparation.groovy b/scripts/release-all/test-maven-preparation.groovy new file mode 100644 index 000000000..467ab4ca6 --- /dev/null +++ b/scripts/release-all/test-maven-preparation.groovy @@ -0,0 +1,113 @@ +#!/usr/bin/env groovy +import groovy.json.JsonOutput +import java.nio.file.Files +import java.nio.file.attribute.PosixFilePermissions +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream +import java.util.zip.ZipFile + +// Signs only disposable fixture coordinates. No deploy goal or publishing endpoint is configured. +File root = new File(args ? args[0] : '.').canonicalFile +File plugin = new File(args.size() > 1 ? args[1] : '../nexus-release-plugin').canonicalFile +File output = new File(root, 'build/releaseAll'); output.mkdirs() +File fixture = Files.createTempDirectory(output.toPath(), 'signed-maven-').toFile() +File keys = new File(fixture, 'gpg'); keys.mkdirs() +Files.setPosixFilePermissions(keys.toPath(), PosixFilePermissions.fromString('rwx------')) +File log = new File(output, 'signed-maven-test.log') +log.text = '' +def run = { List command, File directory = fixture -> + def builder = new ProcessBuilder(command).directory(directory).redirectErrorStream(true) + builder.environment().put('GNUPGHOME', keys.path) + Process process = builder.start() + String text = process.inputStream.text + log.append(text) + assert process.waitFor() == 0: "Fixture command failed; inspect ${log}" + text.trim() +} +try { + run(['gpg', '--batch', '--pinentry-mode', 'loopback', '--passphrase', '', '--quick-generate-key', 'Release fixture ', 'rsa2048', 'sign', '1d']) + String fingerprint = run(['gpg', '--batch', '--with-colons', '--list-secret-keys']).readLines().find { it.startsWith('fpr:') }.split(':')[9] + String group = 'se.alipsa.matrix.releasefixture' + String version = '0.0.0-fixture' + String metadata = 'Release fixtureOffline publication preparation testhttps://example.invalidMIThttps://example.invalid/licenseFixturehttps://example.invalid/reposcm:git:https://example.invalid/repo' + String signing = 'releaseorg.apache.maven.pluginsmaven-gpg-plugin3.2.8verifysign--pinentry-modeloopback' + String library = "${group}library1" + File bom = new File(fixture, 'bom.xml') + bom.text = "4.0.0${group}bom${version}pom${metadata}${library}${signing}" + File all = new File(fixture, 'pom.xml') + String flatten = 'org.codehaus.mojoflatten-maven-plugin1.7.3ossrhprocess-resourcesflattenorg.apache.maven.pluginsmaven-jar-plugin3.4.2' + all.text = "4.0.0${group}all${version}${metadata}${group}bom${version}pomimport${library.replace('1', '')}${flatten}${signing}" + File settings = new File(fixture, 'user-settings.xml') + settings.text = "fixture-signing${fingerprint}fixture-signing" + String originalSettings = settings.text + File helper = new File(fixture, 'settings-helper.groovy'); helper.bytes = new File(root, 'scripts/release-all/staged-settings.groovy').bytes + File verifier = new File(fixture, 'verify.sh') + verifier.text = '#!/usr/bin/env bash\nset -euo pipefail\n[[ "$1" == --manifest && "$3" == --staging && "$5" == --local ]]\necho fixture-verifier-called\n' + new File(fixture, '.gitignore').text = 'build/\n.gradle/\ngpg/\ntarget/\n.flattened-pom.xml\n' + new File(fixture, 'settings.gradle').text = "pluginManagement { includeBuild(${JsonOutput.toJson(plugin.path)}) }; rootProject.name='signed-preparation-fixture'" + new File(fixture, 'build.gradle').text = '''plugins { id 'se.alipsa.nexus-release-plugin' } +nexusReleasePlugin.combinedRelease { } +releaseAllPrepareMaven { + sourceDirectory = layout.projectDirectory + manifestFile = layout.buildDirectory.file('manifest.json') + stagingDirectory = layout.buildDirectory.dir('staging') + localRepository = layout.buildDirectory.dir('maven-local') + mavenWorkDirectory = layout.projectDirectory + verifierFile = layout.projectDirectory.file('verify.sh') + settingsHelperFile = layout.projectDirectory.file('settings-helper.groovy') + userSettingsFile = layout.projectDirectory.file('user-settings.xml') +} +bundleAll { + sourceDirectory = layout.projectDirectory + manifestFile = layout.buildDirectory.file('manifest.json') + stagingDirectory = layout.buildDirectory.dir('staging') + bundleFile = layout.buildDirectory.file('bundle.zip') + receiptFile = layout.buildDirectory.file('receipt.json') +} +''' + run(['git', 'init']); run(['git', 'add', '.']) + run(['git', '-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', 'commit', '-m', 'Signed preparation fixture']) + File stage = new File(fixture, 'build/staging') + File libraryDir = new File(stage, group.replace('.', '/') + '/library/1'); libraryDir.mkdirs() + new File(libraryDir, 'library-1.pom').text = "4.0.0${group}library1${metadata}" + new ZipOutputStream(new FileOutputStream(new File(libraryDir, 'library-1.jar'))).withCloseable { zip -> + zip.putNextEntry(new ZipEntry('fixture.txt')); zip.write('fixture'.bytes); zip.closeEntry() + } + Map edge = [groupId: group, artifactId: 'library', version: '1', scope: 'compile', type: 'jar', classifier: ''] + File libraryMetadata = new File(libraryDir, 'library-1.module') + libraryMetadata.text = JsonOutput.toJson([formatVersion: '1.1', component: [group: group, module: 'library', version: '1'], variants: []]) + run(['gpg', '--batch', '--yes', '--armor', '--detach-sign', libraryMetadata.path]) + List selected = [[groupId: group, artifactId: 'bom', version: version, packaging: 'pom', role: 'bom', pomFile: 'bom.xml', files: ["bom-${version}.pom"], publishedDependencies: [], publishedManagement: [edge]], + [groupId: group, artifactId: 'all', version: version, packaging: 'jar', role: 'aggregate', sourcesAndJavadocRequired: false, pomFile: 'pom.xml', files: ["all-${version}.pom", "all-${version}.jar"], publishedDependencies: [edge], publishedManagement: []], + [projectPath: ':library', groupId: group, artifactId: 'library', version: '1', packaging: 'jar', role: 'gradle', sourcesAndJavadocRequired: false, pomFile: 'build/staging/' + group.replace('.', '/') + '/library/1/library-1.pom', files: ['library-1.pom', 'library-1.jar', 'library-1.module'], publishedDependencies: [], publishedManagement: []]] + ['pom', 'jar'].each { extension -> run(['gpg', '--batch', '--yes', '--armor', '--detach-sign', new File(libraryDir, "library-1.${extension}").path]) } + new File(fixture, 'build/manifest.json').text = JsonOutput.toJson([revision: run(['git', 'rev-parse', 'HEAD']), hashes: [:], versions: [:], selected: selected]) + run([new File(root, 'gradlew').path, '-p', fixture.path, 'releaseAllPrepareMaven', 'bundleAll', '--no-configuration-cache', '--max-workers=2']) + selected.each { component -> + component.files.each { name -> + File artifact = new File(stage, "${group.replace('.', '/')}/${component.artifactId}/${component.version}/${name}") + assert artifact.isFile() && new File(artifact.path + '.asc').isFile() + run(['gpg', '--batch', '--verify', artifact.path + '.asc', artifact.path]) + } + } + assert settings.text == originalSettings + assert !new File(fixture, 'build').listFiles().any { it.name.startsWith('signing-settings-') } + assert log.text.contains('fixture-verifier-called') && log.text.contains('flatten:') + assert !log.text.contains('maven-deploy-plugin') + assert new File(fixture, 'build/bundle.zip').isFile() + new ZipFile(new File(fixture, 'build/bundle.zip')).withCloseable { zip -> + String metadataPath = "${group.replace('.', '/')}/library/1/library-1.module" + assert zip.getEntry(metadataPath) != null + assert zip.getEntry(metadataPath + '.asc') != null + } + assert run(['git', 'status', '--porcelain']).empty + println "Signed Maven preparation passed: exact staged dependency, BOM install, flattened aggregate POM, GPG signatures, allow-listed bundle and preserved user settings. Log: ${log}" +} finally { + try { + def shutdown = new ProcessBuilder('gpgconf', '--kill', 'gpg-agent') + shutdown.environment().put('GNUPGHOME', keys.path) + shutdown.redirectErrorStream(true).start().waitFor() + } finally { + fixture.deleteDir() + } +} diff --git a/scripts/release-all/test-tools.groovy b/scripts/release-all/test-tools.groovy new file mode 100644 index 000000000..3cb075a3e --- /dev/null +++ b/scripts/release-all/test-tools.groovy @@ -0,0 +1,191 @@ +#!/usr/bin/env groovy +import groovy.json.JsonOutput +import groovy.json.JsonSlurper +import java.nio.file.Files + +File root = new File(args ? args[0] : '.').canonicalFile +File temporary = Files.createTempDirectory('matrix-release-tools').toFile().canonicalFile +int assertions = 0 +try { + def run = { List command, Map environment = [:] -> + def builder = new ProcessBuilder(command).redirectErrorStream(true) + builder.environment().putAll(environment) + Process process = builder.start() + String text = process.inputStream.text + int code = process.waitFor() + [code: code, text: text] + } + File preflight = new File(root, 'scripts/release-all/preflight.sh') + ['release', 'bom'].each { mode -> + ['21', '21.0.12.1', '20.0.2', '22', '210'].each { version -> + def checked = run(['bash', '-c', 'source "$1"; fixture_version=$2; java() { echo "openjdk version \\"$fixture_version\\"" >&2; }; mvn() { echo "Apache Maven 3.9.9"; }; groovy() { :; }; RELEASE_ROOT=$3; release_tools_preflight "$4"', 'fixture', preflight.path, version, root.path, mode]) + boolean accepted = mode == 'release' ? version in ['21', '21.0.12.1'] : version != '20.0.2' + assert (checked.code == 0) == accepted: checked.text + assertions++ + } + } + File paths = new File(root, 'scripts/release-all/Paths.groovy') + File withSpace = new File(temporary, 'with space'); withSpace.mkdirs() + new File(withSpace, 'module').mkdirs() + new File(withSpace, 'artifact.pom').text = 'pom' + def result = run(['groovy', paths.path, withSpace.path, new File(temporary, 'missing/child').path]) + assert result.code == 0 + def normalized = new JsonSlurper().parseText(result.text) + assert normalized[0].entries == ['artifact.pom', 'module'] + assert normalized[0].directories == ['module'] + assert normalized[1].path.endsWith('/missing/child') + assertions += 4 + assert run(['groovy', paths.path, temporary.path + '/../unsafe']).code != 0 + File link = new File(temporary, 'link') + Files.createSymbolicLink(link.toPath(), withSpace.toPath()) + assert run(['groovy', paths.path, new File(link, 'child').path]).code != 0 + def writableAndProtected = run(['groovy', paths.path, withSpace.path, link.path], [RELEASE_PATHS_STRICT_COUNT: '1']) + assert writableAndProtected.code == 0 + assert new JsonSlurper().parseText(writableAndProtected.text)[1].path == withSpace.path + def rejectedLink = run(['groovy', paths.path, new File(link, 'child').path]) + assert rejectedLink.text.contains(link.path) + assertions += 5 + + // Exercise the verifier's actual guards without including or executing any deletion/build code. + String verifier = new File(root, 'matrix-bom/verifyBomApi.sh').text + String guards = verifier.substring(verifier.indexOf('reject_dotdot() {'), verifier.indexOf('\nnorm() {')) + File guardScript = new File(temporary, 'guards.sh') + guardScript.text = '''#!/usr/bin/env bash +set -euo pipefail +REPO=$1 +ROOT_DIR=$2 +BOM_DIR="$ROOT_DIR/matrix-bom" +USER_REPO_SET=x +''' + guards + '\nassert_safe_repo_path\n' + File homeDirectory = new File(System.getProperty('user.home')) + [root, root.parentFile, new File(root, 'matrix-bom'), homeDirectory, new File(homeDirectory, '.m2/repository'), new File('/')].each { protectedPath -> + assert run(['bash', guardScript.path, protectedPath.path, root.path]).code != 0 + } + File repository = new File(temporary, 'guard repository'); repository.mkdirs() + assert run(['bash', guardScript.path, repository.path, root.path]).code != 0 + new File(repository, '.matrix-bom-verify-repo').text = '' + assert run(['bash', guardScript.path, repository.path, root.path]).code == 0 + new File(repository, 'settings.gradle').text = '// protected project' + assert run(['bash', guardScript.path, repository.path, root.path]).code != 0 + assert run(['bash', guardScript.path, repository.path + '/../unsafe', root.path]).code != 0 + assert run(['bash', guardScript.path, new File(link, 'repository').path, root.path]).code != 0 + assertions += 11 + + File settings = new File(temporary, 'settings.xml') + settings.text = '''existingfixture-onlysigningfixture-keysigning''' + String originalSettings = settings.text + File generated = new File(temporary, 'generated-settings.xml') + File settingsHelper = new File(root, 'scripts/release-all/staged-settings.groovy') + result = run(['groovy', settingsHelper.path, settings.path, withSpace.path, generated.path, 'matrix-release-staging']) + assert result.code == 0 + def merged = new groovy.xml.XmlParser(false, false).parse(generated) + assert merged.servers.server.password.text() == 'fixture-only' + assert merged.profiles.profile.find { it.id.text() == 'signing' }.properties.'gpg.keyname'.text() == 'fixture-key' + assert merged.activeProfiles.activeProfile*.text() == ['signing', 'matrix-release-staging'] + assert merged.profiles.profile.find { it.id.text() == 'matrix-release-staging' }.repositories.repository.url.text() == withSpace.toURI().toString() + assert Files.getPosixFilePermissions(generated.toPath()) == java.nio.file.attribute.PosixFilePermissions.fromString('rw-------') + assert settings.text == originalSettings + assert run(['groovy', settingsHelper.path, generated.path, withSpace.path, new File(temporary, 'conflict.xml').path, 'matrix-release-staging']).code != 0 + assertions += 8 + + File bom = new File(temporary, 'bom.xml') + bom.text = '0.3.0matrix-arff${arff.version}' + File bomManifest = new File(temporary, 'bom-manifest.json') + Map selectedArff = [projectPath: ':matrix-arff', groupId: 'se.alipsa.matrix', artifactId: 'matrix-arff', version: '0.3.2', files: []] + bomManifest.text = JsonOutput.toJson([selected: [selectedArff]]) + File bomHelper = new File(root, 'scripts/release-all/manifest-bom.groovy') + result = run(['groovy', bomHelper.path, bomManifest.path, bom.path, temporary.path]) + assert result.code == 0 && result.text.trim() == '__bom_selected=false' + bomManifest.text = JsonOutput.toJson([selected: [selectedArff, [artifactId: 'matrix-bom']]]) + assert run(['groovy', bomHelper.path, bomManifest.path, bom.path, temporary.path]).code != 0 + assertions += 2 + + File project = new File(temporary, 'fixture'); project.mkdirs() + File mapping = new File(project, 'scripts/release-all/external-requirements.json') + mapping.parentFile.mkdirs(); mapping.bytes = new File(root, 'scripts/release-all/external-requirements.json').bytes + File test = new File(project, 'matrix-gsheets/src/test/groovy/Test.groovy'); test.parentFile.mkdirs() + test.text = "package test.alipsa.matrix.gsheets\nimport org.junit.jupiter.api.Tag\n@Tag('external') class GsTest { @org.junit.jupiter.api.Test void testExport() {} }" + File manifest = new File(temporary, 'manifest.json') + manifest.text = JsonOutput.toJson([verificationScope: [':matrix-gsheets']]) + File inventory = new File(root, 'scripts/release-all/external-inventory.groovy') + result = run(['groovy', inventory.path, manifest.path, project.path], [RUN_EXTERNAL_TESTS: 'true']) + assert result.code == 0 && result.text.readLines().last() == 'gsheets' + test.text = "package test.alipsa.matrix.gsheets\n// @Tag('external')\nclass GsAuthenticatorTest {}" + assert run(['groovy', inventory.path, manifest.path, project.path], [RUN_EXTERNAL_TESTS: 'true']).code == 0 + test.text = "import org.junit.jupiter.api.Tag\n@Tag('external') class UnmappedTest { @org.junit.jupiter.api.Test void testUnknown() {} }" + assert run(['groovy', inventory.path, manifest.path, project.path], [RUN_EXTERNAL_TESTS: 'true']).code != 0 + test.text = "package test.alipsa.matrix.gsheets\nimport org.junit.jupiter.api.*\n@Tag('external') class GsTest { @Test @Tag('flaky') void testExport() {} }" + result = run(['groovy', inventory.path, manifest.path, project.path], [RUN_EXTERNAL_TESTS: 'true']) + assert result.code == 0 && !result.text.trim() + test.text = "package test.alipsa.matrix.gsheets\nimport org.junit.jupiter.api.*\n@Tag('external') class GsTest { @Test @Disabled void testExport() {} }" + result = run(['groovy', inventory.path, manifest.path, project.path], [RUN_EXTERNAL_TESTS: 'true']) + assert result.code == 0 && !result.text.trim() + test.text = "package test.alipsa.matrix.gsheets\nimport org.junit.jupiter.api.*\n@Tag('external') class GsTest { @Test @Tag('slow') void testExport() {} }" + result = run(['groovy', inventory.path, manifest.path, project.path], [RUN_EXTERNAL_TESTS: 'true', RUN_SLOW_TESTS: 'false']) + assert result.code == 0 && !result.text.trim() + test.text = "package test.alipsa.matrix.gsheets\nimport org.junit.jupiter.api.Tag as TestTag\n@TestTag('external') class GsTest { @org.junit.jupiter.api.Test void testExport() {} }" + result = run(['groovy', inventory.path, manifest.path, project.path], [RUN_EXTERNAL_TESTS: 'true']) + assert result.code == 0 && result.text.readLines().last() == 'gsheets' + assertions += 4 + assert run(['groovy', inventory.path, manifest.path, project.path], [RUN_EXTERNAL_TESTS: 'false']).code == 0 + assert run(['groovy', inventory.path, manifest.path, project.path], [RUN_EXTERNAL_TESTS: 'false', RELEASE_ALL_DEDICATED_EXTERNAL_TESTS: 'true']).code != 0 + File composed = new File(test.parentFile, 'Slow.groovy') + composed.text = "package testutil\n@org.junit.jupiter.api.Tag('slow') @interface Slow {}" + test.text = "package test.alipsa.matrix.gsheets\nimport testutil.Slow\n@org.junit.jupiter.api.Tag('external') @Slow class GsTest { @org.junit.jupiter.api.Test void testExport() {} }" + result = run(['groovy', inventory.path, manifest.path, project.path], [RUN_EXTERNAL_TESTS: 'true', RUN_SLOW_TESTS: 'false']) + assert result.code == 0 && !result.text.trim(): result.text + composed.delete() + result = run(['groovy', inventory.path, manifest.path, project.path], [RUN_EXTERNAL_TESTS: 'true']) + assert result.code != 0 && result.text.contains('Cannot resolve test annotation testutil.Slow') + File base = new File(test.parentFile, 'Base.groovy') + base.text = "@org.junit.jupiter.api.Tag('external') class Base {}" + test.text = 'class Child extends Base { @org.junit.jupiter.api.Test void inherited() {} }' + result = run(['groovy', inventory.path, manifest.path, project.path], [RUN_EXTERNAL_TESTS: 'true']) + assert result.code != 0 && result.text.contains('inherited test tags') + base.delete() + assertions += 3 + test.delete() + File javaTest = new File(project, 'matrix-gsheets/src/test/java/JavaFixture.java') + javaTest.parentFile.mkdirs() + javaTest.text = '// @Tag("external")\nclass JavaFixture {}' + assert run(['groovy', inventory.path, manifest.path, project.path], [RUN_EXTERNAL_TESTS: 'true']).code == 0 + javaTest.text = 'import org.junit.jupiter.api.Tag; @Tag("external") class JavaFixture { @org.junit.jupiter.api.Test void testUnknown() {} }' + assert run(['groovy', inventory.path, manifest.path, project.path], [RUN_EXTERNAL_TESTS: 'true']).code != 0 + javaTest.text = 'import custom.Slow; @Slow class JavaFixture { @org.junit.jupiter.api.Test void testUnknown() {} }' + result = run(['groovy', inventory.path, manifest.path, project.path], [RUN_EXTERNAL_TESTS: 'true']) + assert result.code != 0 && result.text.contains('Cannot resolve test annotation custom.Slow') + // The base name intentionally differs from its filename to exercise AST indexing. + javaTest.text = '@org.junit.jupiter.api.Tag("external") class TaggedBase {} class Child extends TaggedBase {}' + result = run(['groovy', inventory.path, manifest.path, project.path], [RUN_EXTERNAL_TESTS: 'true']) + assert result.code != 0 && result.text.contains('inherited test tags') + assertions += 2 + javaTest.delete() + Map fixtureMappings = new JsonSlurper().parse(mapping) as Map + fixtureMappings['test.alipsa.matrix.gsheets.Outer$Inner#nested'] = [module: ':matrix-gsheets', requirements: ['gsheets']] + mapping.text = JsonOutput.toJson(fixtureMappings) + ['groovy', 'java'].each { language -> + File nested = language == 'java' ? javaTest : test + nested.text = language == 'java' ? + 'package test.alipsa.matrix.gsheets; import org.junit.jupiter.api.*; @Tag("external") class Outer { @Nested class Inner { @Test void nested() {} } }' : + "package test.alipsa.matrix.gsheets\nimport org.junit.jupiter.api.*\n@Tag('external') class Outer { @Nested class Inner { @Test void nested() {} } }" + result = run(['groovy', inventory.path, manifest.path, project.path], [RUN_EXTERNAL_TESTS: 'true']) + assert result.code == 0 && result.text.readLines().last() == 'gsheets': result.text + nested.text = nested.text.replace('class Outer', '@Disabled class Outer') + result = run(['groovy', inventory.path, manifest.path, project.path], [RUN_EXTERNAL_TESTS: 'true']) + assert result.code == 0 && !result.text.trim(): result.text + nested.delete() + assertions += 2 + } + javaTest.delete() + assertions += 2 + assertions += 5 + // Current BOM tests contain no external tags and add no credentials or endpoint probes. + manifest.text = JsonOutput.toJson([selected: [[artifactId: 'matrix-bom']], verificationScope: []]) + result = run(['groovy', inventory.path, manifest.path, root.path], [RUN_EXTERNAL_TESTS: 'true']) + assert result.code == 0 && !result.text.trim() + assertions++ + println "Portable path and external annotation inventory checks passed (${assertions} assertions)." +} finally { + // Only the freshly created test fixture is removed; production helpers never delete paths. + temporary.deleteDir() +} diff --git a/scripts/release-all/test-workflow.groovy b/scripts/release-all/test-workflow.groovy new file mode 100644 index 000000000..7d23e8440 --- /dev/null +++ b/scripts/release-all/test-workflow.groovy @@ -0,0 +1,131 @@ +#!/usr/bin/env groovy +import groovy.json.JsonOutput +import java.nio.file.Files + +File root = new File(args ? args[0] : '.').canonicalFile +File temporary = Files.createTempDirectory('matrix-release-workflow').toFile().canonicalFile +try { + File fixture = new File(temporary, 'matrix'); fixture.mkdirs() + ['releaseAll.sh', 'scripts/release-all/Paths.groovy', 'scripts/release-all/preflight.sh', 'scripts/release-all/external-inventory.groovy', 'scripts/release-all/external-requirements.json'].each { String path -> + File destination = new File(fixture, path); destination.parentFile.mkdirs() + destination.bytes = new File(root, path).bytes + } + File plugin = new File(temporary, 'plugin/src/main/groovy/se/alipsa/gradle/plugin/release/ReleaseAllPlanTask.groovy') + plugin.parentFile.mkdirs(); plugin.text = '// workflow fixture; no real builds or uploads' + File gradle = new File(fixture, 'gradlew') + gradle.text = '''#!/usr/bin/env bash +set -euo pipefail +mkdir -p build/releaseAll +printf '%s\\n' "$*" >> build/commands.txt +for argument in "$@"; do + if [[ "$argument" == releaseAllPlan ]]; then cp manifest-seed.json build/releaseAll/manifest.json; fi + if [[ "$argument" == :matrix-gsheets:releaseAuthPreflight && "${FAIL_AUTH_PREFLIGHT:-false}" == true ]]; then echo 'Missing or expired ADC'; exit 1; fi +done +''' + gradle.setExecutable(true) + new File(fixture, '.gitignore').text = 'build/\n' + File seed = new File(fixture, 'manifest-seed.json') + seed.text = JsonOutput.toJson([selected: [], verificationScope: []]) + def execute = { List command, Map environment = [:], File directory = temporary -> + def builder = new ProcessBuilder(command).directory(directory).redirectErrorStream(true) + builder.environment().putAll(environment) + Process process = builder.start() + String output = process.inputStream.text + [code: process.waitFor(), output: output] + } + execute(['git', '-C', fixture.path, 'init']) + execute(['git', '-C', fixture.path, 'add', '.']) + execute(['git', '-C', fixture.path, '-c', 'user.name=Test', '-c', 'user.email=test@example.org', 'commit', '-m', 'Workflow fixture']) + def release = { List options, Map overrides = [:] -> + execute(['bash', new File(fixture, 'releaseAll.sh').path] + options, + [RUN_EXTERNAL_TESTS: 'false', RUN_SLOW_TESTS: 'false', RELEASE_ALL_DEDICATED_EXTERNAL_TESTS: 'false', RELEASE_ALL_PLUGIN_DIR: new File(temporary, 'plugin').path] + overrides) + } + [['', 'foo'], ['--dry-run', 'x'], ['--bundle-only', 'x'], ['--resume'], ['--resume', ''], ['--unknown']].each { options -> + def invalid = release(options) + assert invalid.code == 2 && invalid.output.contains('usage: ./releaseAll.sh'): invalid.output + assert !new File(fixture, 'build/commands.txt').exists(): 'Invalid arguments must not invoke Gradle' + } + def commands = { new File(fixture, 'build/commands.txt').readLines() } + def result = release(['--dry-run']) + assert result.code == 0: result.output + assert result.output.contains('No eligible unreleased components') + assert commands().size() == 1 && commands().first().contains('releaseAllPlan') + assert commands().first().contains('-PreleaseAllPluginDir=') + new File(fixture, 'build/commands.txt').delete() + result = release(['--dry-run'], [RELEASE_ALL_PLUGIN_DIR: '']) + assert result.code == 0: result.output + assert commands().size() == 1 && !commands().first().contains('-PreleaseAllPluginDir=') + new File(fixture, 'build/commands.txt').delete() + seed.text = JsonOutput.toJson([selected: [[projectPath: ':matrix-core']], verificationScope: [':matrix-core', ':matrix-csv']]) + execute(['git', '-C', fixture.path, 'add', '.']) + execute(['git', '-C', fixture.path, '-c', 'user.name=Test', '-c', 'user.email=test@example.org', 'commit', '-m', 'Selected fixture']) + result = release(['--bundle-only']) + assert result.code == 0: result.output + assert commands().any { it.contains(':matrix-core:build') && it.contains(':matrix-csv:build') && it.contains('-Pheadless=true') } + assert commands().findIndexOf { it.contains('codenarcMain') } < commands().findIndexOf { it.contains('spotlessCheck') } + assert commands().findIndexOf { it.contains('spotlessCheck') } < commands().findIndexOf { it.contains(':matrix-core:build') } + assert !commands().any { it.contains('externalTest') || it.endsWith(' releaseAll') } + assert commands().any { it.contains('releaseAllStage') } + assert commands().any { it.contains('releaseAllPrepareMaven') } + assert commands().last().endsWith('bundleAll') + new File(fixture, 'build/commands.txt').delete() + result = release([]) + assert result.code == 0: result.output + assert commands().count { it.endsWith(' releaseAll') } == 1 + new File(fixture, 'build/commands.txt').delete() + File bin = new File(fixture, 'bin'); bin.mkdirs() + String realGroovy = execute(['bash', '-c', 'command -v groovy']).output.trim() + File groovy = new File(bin, 'groovy') + groovy.text = '''#!/usr/bin/env bash +if [[ "$*" == *raw.githubusercontent.com* ]]; then echo 'Fixture endpoint unavailable' >&2; exit 1; fi +exec "$REAL_GROOVY" "$@" +''' + groovy.setExecutable(true) + File docker = new File(bin, 'docker'); docker.text = '#!/usr/bin/env bash\nexit "${DOCKER_FIXTURE_STATUS:-0}"\n'; docker.setExecutable(true) + Map external = [RUN_EXTERNAL_TESTS: 'true', REAL_GROOVY: realGroovy, PATH: bin.path + File.pathSeparator + System.getenv('PATH')] + def selectModule = { String module, String className, String method -> + File test = new File(fixture, "${module.substring(1)}/src/test/groovy/Fixture.groovy"); test.parentFile.mkdirs() + String packageName = className.substring(0, className.lastIndexOf('.')) + String simpleName = className.substring(className.lastIndexOf('.') + 1) + test.text = "package ${packageName}\nimport org.junit.jupiter.api.*\n@Tag('external') class ${simpleName} { @Test void ${method}() {} }" + seed.text = JsonOutput.toJson([selected: [[projectPath: module]], verificationScope: [module]]) + execute(['git', '-C', fixture.path, 'add', '.']) + execute(['git', '-C', fixture.path, '-c', 'user.name=Test', '-c', 'user.email=test@example.org', 'commit', '-m', 'External fixture']) + } + selectModule(':matrix-gsheets', 'test.alipsa.matrix.gsheets.GsTest', 'testExport') + ['--dry-run', '--bundle-only'].each { mode -> + result = release([mode], external + [FAIL_AUTH_PREFLIGHT: 'true']) + assert result.code != 0 && result.output.contains('Missing or expired ADC') + assert commands().any { it.contains(':matrix-gsheets:releaseAuthPreflight') } + assert !commands().any { it.contains('codenarcMain') || it.contains(':build') || it.contains('releaseAllStage') } + new File(fixture, 'build/commands.txt').delete() + } + selectModule(':matrix-datasets', 'datasets.RdatasetsTest', 'fetchInfo') + result = release(['--dry-run'], external) + assert result.code != 0 && result.output.contains('Dataset tests require reachable') + assert !commands().any { it.contains('releaseAuthPreflight') || it.contains(':build') } + new File(fixture, 'build/commands.txt').delete() + selectModule(':matrix-bigquery', 'test.alipsa.matrix.bigquery.BqDataTypesTest', 'testBigDecimal') + result = release(['--dry-run'], external) + assert result.code == 0 && !commands().any { it.contains('releaseExternalTest') } + new File(fixture, 'build/commands.txt').delete() + Map dedicated = external + [RELEASE_ALL_DEDICATED_EXTERNAL_TESTS: 'true', GOOGLE_APPLICATION_CREDENTIALS: new File(temporary, 'missing-adc.json').path] + [[GOOGLE_CLOUD_PROJECT: ''], [GOOGLE_CLOUD_PROJECT: 'fixture', DOCKER_FIXTURE_STATUS: '1'], [GOOGLE_CLOUD_PROJECT: 'fixture', DOCKER_FIXTURE_STATUS: '0']].each { prerequisites -> + result = release(['--dry-run'], dedicated + prerequisites) + assert result.code != 0: result.output + assert !commands().any { it.contains(':build') || it.contains('releaseAllStage') } + new File(fixture, 'build/commands.txt').delete() + } + // Resume must ignore source dirtiness and test prerequisites. + seed.append('dirty') + result = release(['--resume', 'saved-id'], [RUN_EXTERNAL_TESTS: 'false', RELEASE_ALL_DEDICATED_EXTERNAL_TESTS: 'true']) + assert result.code == 0: result.output + assert commands().size() == 1 && commands().first().contains('releaseAllResume=saved-id') + new File(fixture, 'build/commands.txt').delete() + result = release(['--dry-run'], [RELEASE_ALL_DEDICATED_EXTERNAL_TESTS: 'true']) + assert result.code != 0 && result.output.contains('Dedicated external tests require') + assert !new File(fixture, 'build/commands.txt').exists() + println 'Workflow acceptance passed: empty selection, caller directory, scoped build/order, bundle-only, upload once, early authentication/endpoint failures, opt-in BigQuery prerequisites, resume and contradictory flags.' +} finally { + temporary.deleteDir() +} diff --git a/settings.gradle b/settings.gradle index c96dbffc2..8bbc2a08a 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,5 +1,17 @@ pluginManagement { + if (providers.gradleProperty('releaseAllPluginDir').isPresent()) { + includeBuild(providers.gradleProperty('releaseAllPluginDir').get()) + } repositories { + if (providers.gradleProperty('releaseAllMode').isPresent()) { + exclusiveContent { + forRepository { gradlePluginPortal() } + filter { + includeGroup 'se.alipsa.gradle' + includeGroup 'se.alipsa.nexus-release-plugin' + } + } + } mavenLocal() // For local development, use mavenLocal to resolve SNAPSHOT dependencies mavenCentral() gradlePluginPortal()