Skip to content

Commit ea2c770

Browse files
authored
Merge pull request #2 from FacturAPI/docs/add-changelog
ci: improve publish gate, changelog, and slack deploy notifications
2 parents 55bdfef + 439e99a commit ea2c770

3 files changed

Lines changed: 299 additions & 25 deletions

File tree

.github/workflows/publish.yml

Lines changed: 269 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@ on:
44
push:
55
branches:
66
- main
7+
workflow_dispatch:
78

89
permissions:
9-
contents: read
10+
contents: write
1011

1112
jobs:
1213
publish:
@@ -16,9 +17,13 @@ jobs:
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: |

CHANGELOG.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Changelog
2+
3+
All notable changes to this project will be documented in this file.
4+
5+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7+
8+
## [1.0.0] - 2026-04-07
9+
10+
### Added
11+
12+
- Initial official Java SDK release for Facturapi (`io.facturapi:facturapi-java`).
13+
- Typed resources for core domains including customers, products, invoices, organizations, receipts, retentions, webhooks, and catalogs.
14+
- Accessor-based root client API (`facturapi.customers()`, `facturapi.invoices()`, etc.) aligned with modern Java SDK style.
15+
- Deeply typed complement models for `pago`, `nomina`, `carta_porte`, and `comercio_exterior`.
16+
- Java time support with typed date fields (`Instant` and `LocalDate`) using Jackson `JavaTimeModule`.
17+
- Enum coverage for core SAT and document state fields (invoice status/type, payment form/method, cancellation motive/status, tax factor/type, taxability, and related constants).
18+
- Download ergonomics with both byte and stream methods for invoice/retention/receipt files.
19+
- Upload ergonomics with `File` and `byte[]` methods for organization logo and certificate uploads.
20+
- Typed `FacturapiException` surface exposing status and structured error fields (`statusCode`, `errorCode`, `errorPath`).
21+
- CI workflow for pull requests and pushes to `main` (Java 11, 17, 21, and 25).
22+
- Maven Central publish workflow gated by semantic version comparison.
23+
- Bilingual consumer docs (`README.md` and `README.es.md`).
24+
25+
### Changed
26+
27+
- Standardized request auth to `Authorization: Bearer <apiKey>`.
28+
- Adopted global Jackson `snake_case` naming strategy for consistent API-model mapping.
29+
- Unified HTTP transport on OkHttp to support Java server environments and Android-compatible runtimes.

pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@
110110
<configuration>
111111
<publishingServerId>central</publishingServerId>
112112
<autoPublish>true</autoPublish>
113-
<waitUntil>published</waitUntil>
113+
<waitUntil>validated</waitUntil>
114114
</configuration>
115115
</plugin>
116116
<plugin>

0 commit comments

Comments
 (0)