Skip to content

Commit 9d90625

Browse files
committed
Enable automatically staging and promoting Nexus artifacts in CI without intervention
1 parent 1b639ed commit 9d90625

File tree

2 files changed

+350
-7
lines changed

2 files changed

+350
-7
lines changed

.github/workflows/deploy.yml

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -60,14 +60,20 @@ jobs:
6060
run: |-
6161
source ./scripts/common.sh
6262
63-
info "Determining release version to use (this may take a moment)..."
63+
info "Determining group ID..."
64+
group_id="$(./mvnw help:evaluate -q -DforceStdout -Dexpression="project.groupId")"
65+
66+
info "Determining (root) artifact ID..."
67+
artifact_id="$(./mvnw help:evaluate -q -DforceStdout -Dexpression="project.groupId")"
68+
69+
info "Determining release version to use..."
6470
if [[ '${{ inputs.version }}' == "" ]]; then
6571
release_version="$(./mvnw -B help:evaluate -Dexpression=project.version -q -DforceStdout | sed 's/-SNAPSHOT//g')"
6672
else
6773
release_version='${{ inputs.version }}'
6874
fi
6975
70-
success "Will use version ${release_version} for this release"
76+
info "Will release ${group_id}/${artifact_id}/${release_version} to Nexus staging"
7177
7278
info "Preparing and performing the release"
7379
ensure-set OSSRH_USERNAME OSSRH_TOKEN GPG_PASSPHRASE
@@ -92,11 +98,21 @@ jobs:
9298
release:prepare release:perform
9399
SCRIPT
94100
95-
success "Release has been performed successfully"
96-
info "Please login on https://s01.oss.sonatype.org/#stagingRepositories site and mark the"
97-
info "release as closed. Once checks pass, click the 'release' option to promote it to"
98-
info "Maven Central."
99-
101+
success "Release has been promoted to Nexus Staging successfully"
102+
103+
info "Will now promote ${group_id}/${artifact_id}/${release_version} to Maven Central"
104+
105+
run <<<-SCRIPT
106+
./scripts/close-nexus-repository.sh \
107+
-u "${OSSRH_USERNAME} \
108+
-p "${OSSRH_PASSWORD} \
109+
-g "${group_id}" \
110+
-a "${artifact_id} \
111+
-v "${release_version} \
112+
-s "https://s01.oss.sonatype.org/"
113+
SCRIPT
114+
115+
success "Released ${group_id}/${artifact_id}/${release_version} to Maven Central successfully"
100116
env:
101117
OSSRH_USERNAME: ${{ secrets.OSSRH_USERNAME }}
102118
OSSRH_TOKEN: ${{ secrets.OSSRH_TOKEN }}

scripts/close-nexus-repository.sh

Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,327 @@
1+
#!/usr/bin/env bash
2+
#
3+
# Copyright (C) 2022 - 2023, the original author or authors.
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
#
17+
18+
###
19+
### Script that helps automate Nexus auto-staging promotion without using the
20+
### Nexus Maven Plugin which will break when we exclude the acceptance tests from
21+
### being deployed.
22+
###
23+
### This script will:
24+
### 1. Find all open repositories under the current user account on Nexus.
25+
### 2. Filter out any non-"open" repositories.
26+
### 3. Find the repository that corresponds to the given group ID, artifact ID, and version.
27+
### 4. Invoke the close operation on the repository
28+
### 5. Wait for the close operation to end (or time out)
29+
### 6. Check that the close operation succeeded (i.e. all Nexus rules for POM content, signing,
30+
### artifact inclusion, documentation, etc are all green)
31+
### 7. Trigger a promotion (release to Maven Central) or drop (discard the release entirely).
32+
###
33+
### I have written this in such a way that I can hopefully reuse it elsewhere in the future.
34+
###
35+
### Note: this targets Sonatype Nexus Manager v2.x, not v3.x.
36+
###
37+
38+
set -o errexit
39+
set -o nounset
40+
41+
if [[ -n ${DEBUG+undef} ]]; then
42+
set -o xtrace
43+
fi
44+
45+
function usage() {
46+
echo "USAGE: ${BASH_SOURCE[0]} [-h] -a <artifactId> -g <groupId> -v <version> -u <userName> -p <password> -s <server>"
47+
echo " -a <artifactId> The base artifact ID to use. This can be any artifact ID in the project"
48+
echo " and is only used to determine the correct staging repository on Nexus"
49+
echo " to deploy."
50+
echo " -d Drop rather than promote. Default is to promote."
51+
echo " -g <groupId> The group ID for the artifact ID to look for."
52+
echo " -v <version> The expected deployed version to look for."
53+
echo " -u <userName> The Nexus username to use."
54+
echo " -p <password> The Nexus password to use."
55+
echo " -s <server> The Nexus server to use."
56+
echo " -h Show this message and exit."
57+
echo
58+
}
59+
60+
artifact_id=""
61+
operation="promote"
62+
group_id=""
63+
version=""
64+
username=""
65+
password=""
66+
server=""
67+
68+
while getopts "a:dg:hp:s:u:v:" opt; do
69+
case "${opt}" in
70+
a)
71+
artifact_id="${OPTARG}"
72+
;;
73+
d)
74+
operation="drop"
75+
;;
76+
g)
77+
group_id="${OPTARG}"
78+
;;
79+
h)
80+
usage
81+
exit 0
82+
;;
83+
p)
84+
password="${OPTARG}"
85+
;;
86+
s)
87+
# Remove https:// or http:// at the start, remove trailing forward-slash
88+
# shellcheck disable=SC2001
89+
server="$(sed 's#^http://##g; s#https://##g; s#/$##g' <<<"${OPTARG}")"
90+
;;
91+
u)
92+
username="${OPTARG}"
93+
;;
94+
v)
95+
version="${OPTARG}"
96+
;;
97+
? | *)
98+
echo "ERROR: Unrecognised argument"
99+
usage
100+
exit 1
101+
;;
102+
esac
103+
done
104+
105+
for required_arg in artifact_id group_id password server username version; do
106+
if [ -z "${!required_arg}" ]; then
107+
echo "ERROR: Missing required argument: ${required_arg}" >&2
108+
usage
109+
exit 1
110+
fi
111+
done
112+
113+
for command in base64 curl jq; do
114+
if ! command -v "${command}" >/dev/null 2>&1; then
115+
echo "ERROR: ${command} is not on the \$PATH" >&2
116+
exit 2
117+
fi
118+
done
119+
120+
function try-jq() {
121+
local file
122+
file="$(mktemp)"
123+
trap 'rm -f "${file}"' EXIT INT TERM
124+
125+
# pipe into file
126+
cat > "${file}"
127+
128+
if ! jq 2>&1 > /dev/null < "${file}"; then
129+
echo -e "\e[1;31mJQ failed to parse the HTTP response. Content was:\e[0m" >&2
130+
cat "${file}" >&2
131+
return 99
132+
fi
133+
134+
jq "${@}" < "${file}"
135+
}
136+
137+
function accept-json-header() {
138+
echo "Accept: application/json"
139+
}
140+
141+
function authorization-header() {
142+
# Have to use echo -n to avoid the newline at the end.
143+
echo -n "Authorization: Basic: $(echo -n "${username}:${password}" | base64)"
144+
}
145+
146+
function content-type-json-header() {
147+
echo "Content-Type: application/json"
148+
}
149+
150+
function get-staging-repositories() {
151+
local url="https://${server}/service/local/staging/profile_repositories"
152+
echo -e "\e[1;33m[GET ${url}]\e[0m Retrieving repository IDs... (this may be slow) " >&2
153+
154+
if curl \
155+
-X GET \
156+
--fail \
157+
--silent \
158+
--header "$(accept-json-header)" \
159+
--header "$(authorization-header)" \
160+
"${url}" |
161+
try-jq -e -r '.data[] | select(.type == "open" or .type == "closed") | .repositoryId'; then
162+
163+
echo -e "\e[1;32mRetrieved all repository IDs successfully\e[0m" >&2
164+
return 0
165+
else
166+
echo -e "\e[1;31mFailed to retrieve the repository IDs\e[0m" >&2
167+
return 100
168+
fi
169+
}
170+
171+
function is-artifact-in-repository() {
172+
# Group ID has . replaced with /
173+
local path="${group_id//./\/}/${artifact_id}/${version}"
174+
local repository_id="${1?Pass the repository ID}"
175+
local url="https://${server}/service/local/repositories/${repository_id}/content/${path}/"
176+
177+
echo -e "\e[1;33m[GET ${url}]\e[0m" >&2
178+
if curl \
179+
-X GET \
180+
--fail \
181+
--silent \
182+
--header "$(accept-json-header)" \
183+
--header "$(authorization-header)" \
184+
"${url}" |
185+
try-jq '.' > /dev/null; then
186+
187+
echo -e "\e[1;32mFound artifact in repository ${repository_id}, will close this repository\e[0m" >&2
188+
return 0
189+
else
190+
echo -e "\e[1;31mArtifact is not present in repository ${repository_id}, skipping\e[0m" >&2
191+
return 101
192+
fi
193+
}
194+
195+
function find-correct-repository-id() {
196+
for repository_id in $(get-staging-repositories); do
197+
if is-artifact-in-repository "${repository_id}"; then
198+
echo "${repository_id}"
199+
return 0
200+
fi
201+
done
202+
203+
echo -e "\e[1;31mERROR: Could not find the artifact in any open repositories\e[0m" >&2
204+
return 102
205+
}
206+
207+
function close-staging-repository() {
208+
local repository_id="${1?Pass the repository ID}"
209+
local url="https://${server}/service/local/staging/bulk/close"
210+
local payload
211+
payload="$(
212+
jq -cn '{ data: { description: $description, stagedRepositoryIds: [ $repository_id ] } }' \
213+
--arg description "" \
214+
--arg repository_id "${repository_id}"
215+
)"
216+
217+
echo -e "\e[1;33m[POST ${url} ${payload}]\e[0m Triggering the closure process" >&2
218+
219+
if curl \
220+
-X POST \
221+
--fail \
222+
--silent \
223+
--header "$(accept-json-header)" \
224+
--header "$(content-type-json-header)" \
225+
--header "$(authorization-header)" \
226+
--data "${payload}" \
227+
"${url}"; then
228+
229+
echo -e "\e[1;32mStarted closure successfully\e[0m" >&2
230+
return 0
231+
else
232+
echo -e "\e[1;31mFailed to start closure\e[0m" >&2
233+
return 103
234+
fi
235+
}
236+
237+
function wait-for-closure-to-end() {
238+
local repository_id="${1?Pass the repository ID}"
239+
local url="https://${server}/service/local/staging/repository/${repository_id}/activity"
240+
241+
echo -e "\e[1;33m[GET ${url}]\e[0m Waiting for the repository to complete the closure process" >&2
242+
for _ in {1..50}; do
243+
# In our case, the "close" activity will gain the attribute named "stopped" once the process
244+
# is over (we then need to check if it passed or failed separately).
245+
if curl \
246+
-X GET \
247+
--fail \
248+
--silent \
249+
--header "$(accept-json-header)" \
250+
--header "$(authorization-header)" \
251+
"${url}" |
252+
try-jq -e '.[] | select(.name == "close") | .stopped != null' >/dev/null; then
253+
254+
echo -e "\e[1;32mClosure process completed\e[0m" >&2
255+
return 0
256+
else
257+
echo -e "\e[1;32mStill waiting for closure to complete...\e[0m" >&2
258+
fi
259+
sleep 2
260+
done
261+
262+
echo -e "\e[1;31mERROR: Repository did not close after 50 iterations. Is Nexus down?\e[0m" >&2
263+
return 104
264+
}
265+
266+
function ensure-closure-succeeded() {
267+
local repository_id="${1?Pass the repository ID}"
268+
local url="https://${server}/service/local/staging/repository/${repository_id}/activity"
269+
270+
echo -e "\e[1;33m[GET ${url}]\e[0m Checking the closure process succeeded" >&2
271+
# Closure has succeeded if the "close" activity has an event named "repositoryClosed" somewhere.
272+
273+
if curl \
274+
-X GET \
275+
--fail \
276+
--silent \
277+
--header "$(accept-json-header)" \
278+
--header "$(authorization-header)" \
279+
"${url}" |
280+
try-jq -ce '.[] | select(.name == "close") | .events[] | select(.name == "repositoryClosed")'; then
281+
282+
echo -e "\e[1;32mRepository closed successfully\e[0m" >&2
283+
return 0
284+
else
285+
echo -e "\e[1;31mERROR: Repository failed to close, you should check this on the Nexus dashboard\e[0m" >&2
286+
return 105
287+
fi
288+
}
289+
290+
function trigger-drop-or-promote() {
291+
local repository_id="${1?Pass the repository ID}"
292+
local url="https://${server}/service/local/staging/bulk/${operation}"
293+
local payload
294+
payload="$(
295+
jq -cn '{ data: { description: $description, stagedRepositoryIds: [ $repository_id ] } }' \
296+
--arg description "" \
297+
--arg repository_id "${repository_id}"
298+
)"
299+
300+
echo -e "\e[1;33m[POST ${url} ${payload}]\e[0m ${operation^} the staging release" >&2
301+
302+
if curl \
303+
-X POST \
304+
--fail \
305+
--silent \
306+
--header "$(accept-json-header)" \
307+
--header "$(content-type-json-header)" \
308+
--header "$(authorization-header)" \
309+
--data "${payload}" \
310+
"${url}" |
311+
try-jq -ce '.'; then
312+
313+
echo -e "\e[1;32m${operation^} succeeded\e[0m" >&2
314+
return 0
315+
else
316+
echo -e "\e[1;31mERROR: ${operation^} failed\e[0m" >&2
317+
return 106
318+
fi
319+
}
320+
321+
repository_id="$(find-correct-repository-id)"
322+
close-staging-repository "${repository_id}"
323+
wait-for-closure-to-end "${repository_id}"
324+
ensure-closure-succeeded "${repository_id}"
325+
trigger-drop-or-promote "${repository_id}"
326+
327+
echo -e "\e[1;32mRelease ${operation} for repository ${repository_id} completed. Have a nice day :-)\e[0m" >&2

0 commit comments

Comments
 (0)