44 push :
55 branches :
66 - main
7+ workflow_dispatch :
78
89permissions :
9- contents : read
10+ contents : write
1011
1112jobs :
1213 publish :
1617 CENTRAL_TOKEN_USERNAME : ${{ secrets.CENTRAL_TOKEN_USERNAME }}
1718 CENTRAL_TOKEN_PASSWORD : ${{ secrets.CENTRAL_TOKEN_PASSWORD }}
1819 GPG_PASSPHRASE : ${{ secrets.GPG_PASSPHRASE }}
20+ CENTRAL_GROUP_ID : io.facturapi
21+ CENTRAL_ARTIFACT_ID : facturapi-java
1922 steps :
2023 - name : Checkout
2124 uses : actions/checkout@v4
25+ with :
26+ fetch-depth : 0
2227
2328 - name : Setup Java
2429 uses : actions/setup-java@v4
@@ -38,10 +43,16 @@ jobs:
3843 run : |
3944 set -euo pipefail
4045
46+ python3 -m pip install --disable-pip-version-check --quiet semver
47+
4148 python3 <<'PY' >> "$GITHUB_OUTPUT"
49+ import base64
50+ import os
4251 import sys
4352 import urllib.request
53+ import urllib.error
4454 import xml.etree.ElementTree as ET
55+ import semver
4556
4657 ns = {"m": "http://maven.apache.org/POM/4.0.0"}
4758 pom = ET.parse("pom.xml").getroot()
@@ -66,38 +77,272 @@ jobs:
6677 except Exception:
6778 latest = "0.0.0"
6879
69- def parse_semver(value: str):
70- core = value.split("+", 1)[0]
71- main, _, prerelease = core.partition("-")
72- major, minor, patch = (int(part) for part in main.split(".")[:3])
73- return major, minor, patch, prerelease
74-
75- def is_greater(left: str, right: str) -> bool:
76- left_parts = parse_semver(left)
77- right_parts = parse_semver(right)
78- if left_parts[:3] != right_parts[:3]:
79- return left_parts[:3] > right_parts[:3]
80- left_pre = left_parts[3]
81- right_pre = right_parts[3]
82- if left_pre == right_pre:
83- return False
84- if not left_pre:
85- return True
86- if not right_pre:
87- return False
88- return left_pre > right_pre
89-
90- publish = is_greater(version, latest)
80+ def to_semver(value: str):
81+ try:
82+ return semver.Version.parse(value)
83+ except ValueError:
84+ return None
85+
86+ current_semver = to_semver(version)
87+ latest_semver = to_semver(latest) or semver.Version.parse("0.0.0")
88+ if current_semver is None:
89+ print(f"current_version={version}")
90+ print(f"latest_version={latest}")
91+ print("publish=false")
92+ print("reason=current version is not valid semver")
93+ sys.exit(0)
94+
95+ publish = current_semver > latest_semver
96+ reason = "current version is newer than published version"
97+ if publish:
98+ # If a deployment for this exact version is already staged/ongoing in the Portal,
99+ # skip a duplicate publish attempt and leave the workflow green.
100+ username = os.environ.get("CENTRAL_TOKEN_USERNAME", "")
101+ password = os.environ.get("CENTRAL_TOKEN_PASSWORD", "")
102+ group_id = os.environ.get("CENTRAL_GROUP_ID", "io.facturapi")
103+ artifact_id = os.environ.get("CENTRAL_ARTIFACT_ID", "facturapi-java")
104+ if username and password:
105+ token = base64.b64encode(f"{username}:{password}".encode("utf-8")).decode("utf-8")
106+ rel_path = f"{group_id.replace('.', '/')}/{artifact_id}/{version}/{artifact_id}-{version}.pom"
107+ url = f"https://central.sonatype.com/api/v1/publisher/deployments/download/{rel_path}"
108+ req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"}, method="HEAD")
109+ try:
110+ with urllib.request.urlopen(req, timeout=20) as response:
111+ if response.status == 200:
112+ publish = False
113+ reason = "version already staged or publishing in Central Portal"
114+ except urllib.error.HTTPError as err:
115+ if err.code != 404:
116+ print(f"portal_stage_check_status={err.code}")
117+ except Exception:
118+ pass
119+
91120 print(f"current_version={version}")
92121 print(f"latest_version={latest}")
93122 print(f"publish={'true' if publish else 'false'}")
94- print(f"reason={'current version is newer than published version' if publish else 'published version is current or newer'}")
123+ if publish:
124+ reason = "current version is newer than published version"
125+ elif reason == "current version is newer than published version":
126+ reason = "published version is current or newer"
127+ print(f"reason={reason}")
95128 PY
96129
97130 - name : Publish
131+ id : publish_step
98132 if : steps.gate.outputs.publish == 'true'
99133 run : mvn -B -ntp -DskipTests -DpublishRelease=true deploy
100134
135+ - name : Ensure release tag is in sync
136+ if : steps.gate.outputs.publish == 'true' && steps.publish_step.outcome == 'success'
137+ env :
138+ VERSION : ${{ steps.gate.outputs.current_version }}
139+ shell : bash
140+ run : |
141+ set -euo pipefail
142+ tag="v${VERSION}"
143+ current_sha="$(git rev-parse HEAD)"
144+
145+ if git rev-parse "$tag" >/dev/null 2>&1; then
146+ tag_sha="$(git rev-list -n 1 "$tag")"
147+ if [ "$tag_sha" != "$current_sha" ]; then
148+ echo "::error::Tag $tag already exists at $tag_sha, expected $current_sha"
149+ exit 1
150+ fi
151+ echo "Tag $tag already in sync with $current_sha"
152+ exit 0
153+ fi
154+
155+ git config user.name "github-actions[bot]"
156+ git config user.email "github-actions[bot]@users.noreply.github.com"
157+ git tag -a "$tag" -m "Release $tag"
158+ git push origin "$tag"
159+ echo "Created and pushed tag $tag"
160+
161+ - name : Create or update GitHub release notes
162+ if : steps.gate.outputs.publish == 'true' && steps.publish_step.outcome == 'success'
163+ env :
164+ GH_TOKEN : ${{ github.token }}
165+ VERSION : ${{ steps.gate.outputs.current_version }}
166+ shell : bash
167+ run : |
168+ set -euo pipefail
169+ tag="v${VERSION}"
170+ notes_file="/tmp/release_notes.md"
171+
172+ python3 - <<'PY'
173+ import os
174+ from pathlib import Path
175+
176+ version = os.environ["VERSION"]
177+ header = f"## [{version}]"
178+ changelog = Path("CHANGELOG.md")
179+ output = Path("/tmp/release_notes.md")
180+
181+ if not changelog.exists():
182+ output.write_text(f"Release {version}", encoding="utf-8")
183+ raise SystemExit(0)
184+
185+ lines = changelog.read_text(encoding="utf-8").splitlines()
186+ in_target = False
187+ captured = []
188+ for line in lines:
189+ if line.startswith(header):
190+ in_target = True
191+ continue
192+ if in_target and line.startswith("## ["):
193+ break
194+ if in_target:
195+ captured.append(line)
196+
197+ notes = "\n".join(captured).strip()
198+ if not notes:
199+ notes = f"Release {version}"
200+ output.write_text(notes + "\n", encoding="utf-8")
201+ PY
202+
203+ if gh release view "$tag" >/dev/null 2>&1; then
204+ gh release edit "$tag" --title "$tag" --notes-file "$notes_file"
205+ echo "Updated release $tag"
206+ else
207+ gh release create "$tag" --title "$tag" --notes-file "$notes_file"
208+ echo "Created release $tag"
209+ fi
210+
211+ - name : Notify Slack (success)
212+ if : steps.gate.outputs.publish == 'true' && steps.publish_step.outcome == 'success'
213+ env :
214+ SLACK_DEPLOY_WEBHOOK_URL : ${{ secrets.SLACK_DEPLOY_WEBHOOK_URL }}
215+ VERSION : ${{ steps.gate.outputs.current_version }}
216+ shell : bash
217+ run : |
218+ set -euo pipefail
219+ if [ -z "${SLACK_DEPLOY_WEBHOOK_URL:-}" ]; then
220+ echo "SLACK_DEPLOY_WEBHOOK_URL not set; skipping Slack notification"
221+ exit 0
222+ fi
223+
224+ python3 - <<'PY'
225+ import json
226+ import os
227+ from pathlib import Path
228+
229+ version = os.environ["VERSION"]
230+ ref_name = os.environ["GITHUB_REF_NAME"]
231+ sha = os.environ["GITHUB_SHA"]
232+ actor = os.environ["GITHUB_ACTOR"]
233+ server_url = os.environ["GITHUB_SERVER_URL"]
234+ repo = os.environ["GITHUB_REPOSITORY"]
235+ run_id = os.environ["GITHUB_RUN_ID"]
236+
237+ changelog = Path("CHANGELOG.md")
238+ latest_changes = "See CHANGELOG for details."
239+ section_header = f"## [{version}]"
240+ if changelog.exists():
241+ lines = changelog.read_text(encoding="utf-8").splitlines()
242+ in_target = False
243+ bullets = []
244+ for line in lines:
245+ if line.startswith(section_header):
246+ in_target = True
247+ continue
248+ if in_target and line.startswith("## ["):
249+ break
250+ if in_target and line.startswith("- "):
251+ bullets.append(line[2:].strip())
252+ if bullets:
253+ latest_changes = "\n".join(f"• {b}" for b in bullets[:5])
254+
255+ payload = {
256+ "text": f"🚀 Facturapi Java SDK {version} validated and submitted",
257+ "blocks": [
258+ {
259+ "type": "header",
260+ "text": {
261+ "type": "plain_text",
262+ "text": f"🚀 Java SDK {version} validated and submitted to Maven Central"
263+ }
264+ },
265+ {
266+ "type": "section",
267+ "fields": [
268+ {"type": "mrkdwn", "text": f"*Package:* `io.facturapi:facturapi-java:{version}`"},
269+ {"type": "mrkdwn", "text": f"*Branch:* `{ref_name}`"},
270+ {"type": "mrkdwn", "text": f"*Commit:* `{sha}`"},
271+ {"type": "mrkdwn", "text": f"*Actor:* `{actor}`"}
272+ ]
273+ },
274+ {
275+ "type": "section",
276+ "text": {
277+ "type": "mrkdwn",
278+ "text": (
279+ "*Useful links*\n"
280+ f"• Maven Central: <https://central.sonatype.com/artifact/io.facturapi/facturapi-java/{version}|View artifact>\n"
281+ f"• Central deployment status: <https://central.sonatype.com/publishing/deployments|Check progress>\n"
282+ f"• Workflow run: <{server_url}/{repo}/actions/runs/{run_id}|Open run>\n"
283+ f"• Changelog: <{server_url}/{repo}/blob/{sha}/CHANGELOG.md|Read changes>"
284+ )
285+ }
286+ },
287+ {
288+ "type": "section",
289+ "text": {
290+ "type": "mrkdwn",
291+ "text": f"*Latest changes*\n{latest_changes}"
292+ }
293+ }
294+ ]
295+ }
296+
297+ Path("/tmp/slack_payload.json").write_text(json.dumps(payload), encoding="utf-8")
298+ PY
299+
300+ curl -sS -X POST -H "Content-type: application/json" --data "@/tmp/slack_payload.json" "$SLACK_DEPLOY_WEBHOOK_URL" || true
301+
302+ - name : Notify Slack (failure)
303+ if : steps.gate.outputs.publish == 'true' && steps.publish_step.outcome == 'failure'
304+ env :
305+ SLACK_DEPLOY_WEBHOOK_URL : ${{ secrets.SLACK_DEPLOY_WEBHOOK_URL }}
306+ VERSION : ${{ steps.gate.outputs.current_version }}
307+ shell : bash
308+ run : |
309+ set -euo pipefail
310+ if [ -z "${SLACK_DEPLOY_WEBHOOK_URL:-}" ]; then
311+ echo "SLACK_DEPLOY_WEBHOOK_URL not set; skipping Slack notification"
312+ exit 0
313+ fi
314+ python3 - <<'PY'
315+ import json
316+ import os
317+ from pathlib import Path
318+
319+ version = os.environ["VERSION"]
320+ server_url = os.environ["GITHUB_SERVER_URL"]
321+ repo = os.environ["GITHUB_REPOSITORY"]
322+ run_id = os.environ["GITHUB_RUN_ID"]
323+ sha = os.environ["GITHUB_SHA"]
324+
325+ payload = {
326+ "text": f"❌ Facturapi Java SDK {version} publish failed",
327+ "blocks": [
328+ {
329+ "type": "section",
330+ "text": {
331+ "type": "mrkdwn",
332+ "text": (
333+ f"❌ *Java SDK {version} publish failed*\n"
334+ f"• Workflow run: <{server_url}/{repo}/actions/runs/{run_id}|Open run>\n"
335+ f"• Commit: `{sha}`"
336+ )
337+ }
338+ }
339+ ]
340+ }
341+
342+ Path("/tmp/slack_payload_failure.json").write_text(json.dumps(payload), encoding="utf-8")
343+ PY
344+ curl -sS -X POST -H "Content-type: application/json" --data "@/tmp/slack_payload_failure.json" "$SLACK_DEPLOY_WEBHOOK_URL" || true
345+
101346 - name : Skip publish
102347 if : steps.gate.outputs.publish != 'true'
103348 run : |
0 commit comments