Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .github/scripts/publish-release.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
set -euo pipefail

case "${1:-}" in
lib)
changelog_prefix=:
publish_tasks=(:lib:publishToMavenCentral :lib-extra:publishToMavenCentral)
;;
plugin-gradle)
changelog_prefix=:plugin-gradle:
publish_tasks=(:plugin-gradle:publishPlugins :plugin-gradle:publishToMavenCentral)
;;
plugin-maven)
changelog_prefix=:plugin-maven:
publish_tasks=(:plugin-maven:publishToMavenCentral)
;;
*)
echo 'Usage: publish-release.sh {lib|plugin-gradle|plugin-maven}' >&2
exit 2
;;
esac

gradle_args=(-Prelease=true -PmavenCentralDeploymentValidation=PUBLISHED
--stacktrace --warning-mode all --no-configuration-cache --no-scan)
if [[ "$1" == plugin-gradle ]]; then
gradle_args+=("-Pgradle.publish.key=${GRADLE_KEY:?}" "-Pgradle.publish.secret=${GRADLE_SECRET:?}")
fi

# Central uploads happen at the end of a successful Gradle build. Finish that
# build, including waiting for PUBLISHED, before mutating the changelog or Git.
./gradlew "${changelog_prefix}changelogInternalPushWillRun" "${changelog_prefix}changelogCheck" \
"${publish_tasks[@]}" "${gradle_args[@]}"

# The artifacts are already published. Exclude the publishing dependencies from
# the changelog build so it cannot upload them again or defer publication to its end.
exclude_tasks=()
for task in "${publish_tasks[@]}"; do
exclude_tasks+=(-x "$task")
done
./gradlew "${changelog_prefix}changelogPush" -PpublicationConfirmed=true \
"${exclude_tasks[@]}" "${gradle_args[@]}"
70 changes: 70 additions & 0 deletions .github/scripts/test_publish_release.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import json
import os
from pathlib import Path
import subprocess
import tempfile
import unittest


SCRIPT = Path(__file__).with_name("publish-release.sh").resolve()
PUBLICATIONS = {
"lib": [":lib:publishToMavenCentral", ":lib-extra:publishToMavenCentral"],
"plugin-gradle": [":plugin-gradle:publishPlugins", ":plugin-gradle:publishToMavenCentral"],
"plugin-maven": [":plugin-maven:publishToMavenCentral"],
}


class ReleasePublicationTest(unittest.TestCase):
def run_release(self, component, publish_exit):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
wrapper = root / "gradlew"
wrapper.write_text(
"#!/usr/bin/env python3\n"
"import json, os, sys\n"
"with open('calls.jsonl', 'a') as calls:\n"
" calls.write(json.dumps(sys.argv[1:]) + '\\n')\n"
"if any(arg.endswith(':changelogPush') for arg in sys.argv):\n"
" open('tag-created', 'w').close()\n"
"else:\n"
" # Model a failure at build shutdown, after publication tasks finish.\n"
" sys.exit(int(os.environ['PUBLISH_EXIT']))\n"
)
wrapper.chmod(0o755)
result = subprocess.run(
["bash", str(SCRIPT), component],
cwd=root,
env=dict(os.environ, PUBLISH_EXIT=str(publish_exit),
GRADLE_KEY="test-key", GRADLE_SECRET="test-secret"),
capture_output=True,
text=True,
)
calls = [json.loads(line) for line in (root / "calls.jsonl").read_text().splitlines()]
return result, calls, (root / "tag-created").exists()

def test_failed_publication_never_updates_changelog_or_pushes_tag(self):
for component in PUBLICATIONS:
with self.subTest(component=component):
result, calls, tagged = self.run_release(component, 42)
self.assertEqual(result.returncode, 42)
self.assertEqual(len(calls), 1)
self.assertFalse(tagged)
self.assertNotIn("-PpublicationConfirmed=true", calls[0])

def test_confirmed_publication_precedes_changelog_without_republishing(self):
for component, publications in PUBLICATIONS.items():
with self.subTest(component=component):
result, calls, tagged = self.run_release(component, 0)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(len(calls), 2)
self.assertTrue(tagged)
self.assertIn("-PmavenCentralDeploymentValidation=PUBLISHED", calls[0])
self.assertNotIn("-PpublicationConfirmed=true", calls[0])
self.assertIn("-PpublicationConfirmed=true", calls[1])
for task in publications:
self.assertIn(task, calls[0])
self.assertEqual(calls[1][calls[1].index(task) - 1], "-x")


if __name__ == "__main__":
unittest.main()
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ jobs:
distribution: "temurin"
java-version: 21
- uses: gradle/actions/setup-gradle@v6
- name: Check release publication ordering
run: python3 .github/scripts/test_publish_release.py
- run: ./gradlew spotlessCheck
- run: ./gradlew assemble testClasses publishToMavenLocal

Expand Down
58 changes: 52 additions & 6 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ on:
- plugin-maven
- all
- lib
- lib-recover
lib_version:
description: 'Existing lib tag version to publish when using lib-recover (e.g. 4.10.3)'
required: false
type: string

jobs:
build:
Expand All @@ -49,9 +54,19 @@ jobs:
ORG_GRADLE_PROJECT_signingInMemoryKeyId: "0xB74EC7C6"
ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.GPG_PASSPHRASE }}
steps:
- name: Validate library recovery input
if: "${{ inputs.to_publish == 'lib-recover' }}"
env:
LIB_VERSION: ${{ inputs.lib_version }}
run: |
[[ "$LIB_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
echo 'lib-recover requires a released library version, e.g. 4.10.3'
exit 1
}
- uses: actions/checkout@v7
with:
persist-credentials: false
ref: ${{ inputs.to_publish == 'lib-recover' && format('refs/tags/lib/{0}', inputs.lib_version) || github.ref }}
- name: Configure git credentials for JGit
run: |
git config --local http.https://github.com/.extraheader "AUTHORIZATION: basic $(echo -n x-access-token:${{ secrets.GITHUB_TOKEN }} | base64 -w0)"
Expand All @@ -67,19 +82,50 @@ jobs:
run: git -c http.version=HTTP/1.1 fetch origin main
- name: publish all
if: "${{ github.event.inputs.to_publish == 'all' }}"
env:
GRADLE_KEY: ${{ secrets.GRADLE_KEY }}
GRADLE_SECRET: ${{ secrets.GRADLE_SECRET }}
run: |
./gradlew :changelogPush -Prelease=true --stacktrace --warning-mode all --no-configuration-cache
./gradlew :plugin-gradle:changelogPush -Prelease=true -Pgradle.publish.key=${{ secrets.GRADLE_KEY }} -Pgradle.publish.secret=${{ secrets.GRADLE_SECRET }} --stacktrace --warning-mode all --no-configuration-cache
./gradlew :plugin-maven:changelogPush -Prelease=true --stacktrace --warning-mode all --no-configuration-cache
bash .github/scripts/publish-release.sh lib
bash .github/scripts/publish-release.sh plugin-gradle
bash .github/scripts/publish-release.sh plugin-maven
- name: publish just plugin-gradle
if: "${{ github.event.inputs.to_publish == 'plugin-gradle' }}"
env:
GRADLE_KEY: ${{ secrets.GRADLE_KEY }}
GRADLE_SECRET: ${{ secrets.GRADLE_SECRET }}
run: |
./gradlew :plugin-gradle:changelogPush -Prelease=true -Pgradle.publish.key=${{ secrets.GRADLE_KEY }} -Pgradle.publish.secret=${{ secrets.GRADLE_SECRET }} --stacktrace --warning-mode all --no-configuration-cache
bash .github/scripts/publish-release.sh plugin-gradle
- name: publish just plugin-maven
if: "${{ github.event.inputs.to_publish == 'plugin-maven' }}"
run: |
./gradlew :plugin-maven:changelogPush -Prelease=true --stacktrace --warning-mode all --no-configuration-cache
bash .github/scripts/publish-release.sh plugin-maven
- name: publish just lib
if: "${{ github.event.inputs.to_publish == 'lib' }}"
run: |
./gradlew :changelogPush -Prelease=true --stacktrace --warning-mode all --no-configuration-cache
bash .github/scripts/publish-release.sh lib
- name: Recover library artifacts from an existing tag
if: "${{ inputs.to_publish == 'lib-recover' }}"
env:
LIB_VERSION: ${{ inputs.lib_version }}
run: |
# Verify the build's versions without printing Gradle properties or secrets.
cat > "$RUNNER_TEMP/verify-library-version.gradle" <<'EOF'
gradle.projectsEvaluated {
if (rootProject.name != 'spotless') {
return // Init scripts also run for the included build-logic build.
}
[':lib', ':lib-extra'].each { projectPath ->
def library = rootProject.project(projectPath)
if (library.version.toString() != System.getenv('LIB_VERSION')) {
throw new GradleException("Library version does not match the recovery tag: ${projectPath}")
}
}
}
EOF
# End this build successfully so the publishing plugin uploads its bundle.
# Do not run changelogPush: the version and tag already exist.
./gradlew :lib:publishToMavenCentral :lib-extra:publishToMavenCentral \
-Prelease=true -PmavenCentralDeploymentValidation=PUBLISHED \
--init-script "$RUNNER_TEMP/verify-library-version.gradle" \
--stacktrace --warning-mode all --no-configuration-cache --no-scan
4 changes: 4 additions & 0 deletions build-logic/src/main/kotlin/spotless.changelog.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ if (project == rootProject) {
)
}
if (changelogPushTasks.size == 1) {
check(rootProject.findProperty("publicationConfirmed")?.toString() == "true") {
"Publish and confirm the artifacts before pushing release tags. " +
"Use bash .github/scripts/publish-release.sh {lib|plugin-gradle|plugin-maven}."
}
// if the one thing being published is a plugin, make sure there aren't any unreleased
// changes in lib
val isPlugin =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ tasks.jar {
dependsOn(changelogTasks.named("changelogCheck"))
}

// ensures that changelog bump and push only happens if the publish was successful
// These tasks prepare publications, but Central uploads only when the build ends.
// CI uses publish-release.sh to finish publishing before a separate changelog build,
// which excludes these dependencies because the artifacts are already published.
changelogTasks.named("changelogBump") {
dependsOn(tasks.named("publishToMavenCentral"))
// if we have a Gradle plugin, we need to push it up to the plugin portal too
Expand Down
Loading