From 100063b7c0961ea8869989f678e5b9f6ef09f0ed Mon Sep 17 00:00:00 2001 From: javorosas Date: Tue, 7 Apr 2026 21:34:01 +0200 Subject: [PATCH 1/7] docs: add changelog --- CHANGELOG.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..59f22ec --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,29 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.0] - 2026-04-07 + +### Added + +- Initial official Java SDK release for Facturapi (`io.facturapi:facturapi-java`). +- Typed resources for core domains including customers, products, invoices, organizations, receipts, retentions, webhooks, and catalogs. +- Accessor-based root client API (`facturapi.customers()`, `facturapi.invoices()`, etc.) aligned with modern Java SDK style. +- Deeply typed complement models for `pago`, `nomina`, `carta_porte`, and `comercio_exterior`. +- Java time support with typed date fields (`Instant` and `LocalDate`) using Jackson `JavaTimeModule`. +- 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). +- Download ergonomics with both byte and stream methods for invoice/retention/receipt files. +- Upload ergonomics with `File` and `byte[]` methods for organization logo and certificate uploads. +- Typed `FacturapiException` surface exposing status and structured error fields (`statusCode`, `errorCode`, `errorPath`). +- CI workflow for pull requests and pushes to `main` (Java 11, 17, 21, and 25). +- Maven Central publish workflow gated by semantic version comparison. +- Bilingual consumer docs (`README.md` and `README.es.md`). + +### Changed + +- Standardized request auth to `Authorization: Bearer `. +- Adopted global Jackson `snake_case` naming strategy for consistent API-model mapping. +- Unified HTTP transport on OkHttp to support Java server environments and Android-compatible runtimes. From 9e65ddde71c2699c9a4b79cda6bb007d6e87edc3 Mon Sep 17 00:00:00 2001 From: javorosas Date: Tue, 7 Apr 2026 21:39:14 +0200 Subject: [PATCH 2/7] ci: notify slack on sdk deploy --- .github/workflows/publish.yml | 133 ++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ce85f08..62c0910 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -98,6 +98,139 @@ jobs: if: steps.gate.outputs.publish == 'true' run: mvn -B -ntp -DskipTests -DpublishRelease=true deploy + - name: Notify Slack (success) + if: steps.gate.outputs.publish == 'true' && success() + env: + SLACK_DEPLOY_WEBHOOK_URL: ${{ secrets.SLACK_DEPLOY_WEBHOOK_URL }} + VERSION: ${{ steps.gate.outputs.current_version }} + shell: bash + run: | + set -euo pipefail + if [ -z "${SLACK_DEPLOY_WEBHOOK_URL:-}" ]; then + echo "SLACK_DEPLOY_WEBHOOK_URL not set; skipping Slack notification" + exit 0 + fi + + python3 - <<'PY' + import json + import os + from pathlib import Path + + version = os.environ["VERSION"] + ref_name = os.environ["GITHUB_REF_NAME"] + sha = os.environ["GITHUB_SHA"] + actor = os.environ["GITHUB_ACTOR"] + server_url = os.environ["GITHUB_SERVER_URL"] + repo = os.environ["GITHUB_REPOSITORY"] + run_id = os.environ["GITHUB_RUN_ID"] + + changelog = Path("CHANGELOG.md") + latest_changes = "See CHANGELOG for details." + if changelog.exists(): + lines = changelog.read_text(encoding="utf-8").splitlines() + in_latest = False + bullets = [] + for line in lines: + if line.startswith("## [") and not in_latest: + in_latest = True + continue + if in_latest and line.startswith("## ["): + break + if in_latest and line.startswith("- "): + bullets.append(line[2:].strip()) + if bullets: + latest_changes = "\n".join(f"• {b}" for b in bullets[:5]) + + payload = { + "text": f"🚀 Facturapi Java SDK {version} published", + "blocks": [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": f"🚀 Java SDK {version} is live on Maven Central" + } + }, + { + "type": "section", + "fields": [ + {"type": "mrkdwn", "text": f"*Package:* `io.facturapi:facturapi-java:{version}`"}, + {"type": "mrkdwn", "text": f"*Branch:* `{ref_name}`"}, + {"type": "mrkdwn", "text": f"*Commit:* `{sha}`"}, + {"type": "mrkdwn", "text": f"*Actor:* `{actor}`"} + ] + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ( + "*Useful links*\n" + f"• Maven Central: \n" + f"• Workflow run: <{server_url}/{repo}/actions/runs/{run_id}|Open run>\n" + f"• Changelog: <{server_url}/{repo}/blob/main/CHANGELOG.md|Read changes>" + ) + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*Latest changes*\n{latest_changes}" + } + } + ] + } + + Path("/tmp/slack_payload.json").write_text(json.dumps(payload), encoding="utf-8") + PY + + curl -sS -X POST -H "Content-type: application/json" --data "@/tmp/slack_payload.json" "$SLACK_DEPLOY_WEBHOOK_URL" + + - name: Notify Slack (failure) + if: steps.gate.outputs.publish == 'true' && failure() + env: + SLACK_DEPLOY_WEBHOOK_URL: ${{ secrets.SLACK_DEPLOY_WEBHOOK_URL }} + VERSION: ${{ steps.gate.outputs.current_version }} + shell: bash + run: | + set -euo pipefail + if [ -z "${SLACK_DEPLOY_WEBHOOK_URL:-}" ]; then + echo "SLACK_DEPLOY_WEBHOOK_URL not set; skipping Slack notification" + exit 0 + fi + python3 - <<'PY' + import json + import os + from pathlib import Path + + version = os.environ["VERSION"] + server_url = os.environ["GITHUB_SERVER_URL"] + repo = os.environ["GITHUB_REPOSITORY"] + run_id = os.environ["GITHUB_RUN_ID"] + sha = os.environ["GITHUB_SHA"] + + payload = { + "text": f"❌ Facturapi Java SDK {version} publish failed", + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ( + f"❌ *Java SDK {version} publish failed*\n" + f"• Workflow run: <{server_url}/{repo}/actions/runs/{run_id}|Open run>\n" + f"• Commit: `{sha}`" + ) + } + } + ] + } + + Path("/tmp/slack_payload_failure.json").write_text(json.dumps(payload), encoding="utf-8") + PY + curl -sS -X POST -H "Content-type: application/json" --data "@/tmp/slack_payload_failure.json" "$SLACK_DEPLOY_WEBHOOK_URL" + - name: Skip publish if: steps.gate.outputs.publish != 'true' run: | From 8ee6f77ccf19654470ef37d8b63b68324e056876 Mon Sep 17 00:00:00 2001 From: javorosas Date: Tue, 7 Apr 2026 21:40:30 +0200 Subject: [PATCH 3/7] ci: notify slack only on real publish failures --- .github/workflows/publish.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 62c0910..067faca 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -95,11 +95,12 @@ jobs: PY - name: Publish + id: publish_step if: steps.gate.outputs.publish == 'true' run: mvn -B -ntp -DskipTests -DpublishRelease=true deploy - name: Notify Slack (success) - if: steps.gate.outputs.publish == 'true' && success() + if: steps.gate.outputs.publish == 'true' && steps.publish_step.outcome == 'success' env: SLACK_DEPLOY_WEBHOOK_URL: ${{ secrets.SLACK_DEPLOY_WEBHOOK_URL }} VERSION: ${{ steps.gate.outputs.current_version }} @@ -188,7 +189,7 @@ jobs: curl -sS -X POST -H "Content-type: application/json" --data "@/tmp/slack_payload.json" "$SLACK_DEPLOY_WEBHOOK_URL" - name: Notify Slack (failure) - if: steps.gate.outputs.publish == 'true' && failure() + if: steps.gate.outputs.publish == 'true' && steps.publish_step.outcome == 'failure' env: SLACK_DEPLOY_WEBHOOK_URL: ${{ secrets.SLACK_DEPLOY_WEBHOOK_URL }} VERSION: ${{ steps.gate.outputs.current_version }} From ed5ff85b10627261ac8ea383b8fdbae163227067 Mon Sep 17 00:00:00 2001 From: javorosas Date: Tue, 7 Apr 2026 21:45:22 +0200 Subject: [PATCH 4/7] ci: fail only on real deploy errors --- .github/workflows/publish.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 067faca..4e027a9 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -186,7 +186,7 @@ jobs: Path("/tmp/slack_payload.json").write_text(json.dumps(payload), encoding="utf-8") PY - curl -sS -X POST -H "Content-type: application/json" --data "@/tmp/slack_payload.json" "$SLACK_DEPLOY_WEBHOOK_URL" + curl -sS -X POST -H "Content-type: application/json" --data "@/tmp/slack_payload.json" "$SLACK_DEPLOY_WEBHOOK_URL" || true - name: Notify Slack (failure) if: steps.gate.outputs.publish == 'true' && steps.publish_step.outcome == 'failure' @@ -230,7 +230,7 @@ jobs: Path("/tmp/slack_payload_failure.json").write_text(json.dumps(payload), encoding="utf-8") PY - curl -sS -X POST -H "Content-type: application/json" --data "@/tmp/slack_payload_failure.json" "$SLACK_DEPLOY_WEBHOOK_URL" + curl -sS -X POST -H "Content-type: application/json" --data "@/tmp/slack_payload_failure.json" "$SLACK_DEPLOY_WEBHOOK_URL" || true - name: Skip publish if: steps.gate.outputs.publish != 'true' From bb96e5fac23014cc78418bb9ebe2057491c26ce5 Mon Sep 17 00:00:00 2001 From: javorosas Date: Tue, 7 Apr 2026 21:52:07 +0200 Subject: [PATCH 5/7] ci: reduce central wait and add manual trigger --- .github/workflows/publish.yml | 1 + pom.xml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4e027a9..9f3c5d6 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -4,6 +4,7 @@ on: push: branches: - main + workflow_dispatch: permissions: contents: read diff --git a/pom.xml b/pom.xml index bdaf13e..c0ae95b 100644 --- a/pom.xml +++ b/pom.xml @@ -110,7 +110,7 @@ central true - published + validated From c6b6f7f849d8ae563bf0ba238ef933ca508a8506 Mon Sep 17 00:00:00 2001 From: javorosas Date: Tue, 7 Apr 2026 21:56:11 +0200 Subject: [PATCH 6/7] ci: log publish skip reason and keep workflow green --- .github/workflows/publish.yml | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9f3c5d6..fb50c7b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -17,6 +17,8 @@ jobs: CENTRAL_TOKEN_USERNAME: ${{ secrets.CENTRAL_TOKEN_USERNAME }} CENTRAL_TOKEN_PASSWORD: ${{ secrets.CENTRAL_TOKEN_PASSWORD }} GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} + CENTRAL_GROUP_ID: io.facturapi + CENTRAL_ARTIFACT_ID: facturapi-java steps: - name: Checkout uses: actions/checkout@v4 @@ -40,8 +42,11 @@ jobs: set -euo pipefail python3 <<'PY' >> "$GITHUB_OUTPUT" + import base64 + import os import sys import urllib.request + import urllib.error import xml.etree.ElementTree as ET ns = {"m": "http://maven.apache.org/POM/4.0.0"} @@ -89,10 +94,38 @@ jobs: return left_pre > right_pre publish = is_greater(version, latest) + reason = "current version is newer than published version" + if publish: + # If a deployment for this exact version is already staged/ongoing in the Portal, + # skip a duplicate publish attempt and leave the workflow green. + username = os.environ.get("CENTRAL_TOKEN_USERNAME", "") + password = os.environ.get("CENTRAL_TOKEN_PASSWORD", "") + group_id = os.environ.get("CENTRAL_GROUP_ID", "io.facturapi") + artifact_id = os.environ.get("CENTRAL_ARTIFACT_ID", "facturapi-java") + if username and password: + token = base64.b64encode(f"{username}:{password}".encode("utf-8")).decode("utf-8") + rel_path = f"{group_id.replace('.', '/')}/{artifact_id}/{version}/{artifact_id}-{version}.pom" + url = f"https://central.sonatype.com/api/v1/publisher/deployments/download/{rel_path}" + req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"}, method="HEAD") + try: + with urllib.request.urlopen(req, timeout=20) as response: + if response.status == 200: + publish = False + reason = "version already staged or publishing in Central Portal" + except urllib.error.HTTPError as err: + if err.code != 404: + print(f"portal_stage_check_status={err.code}") + except Exception: + pass + print(f"current_version={version}") print(f"latest_version={latest}") print(f"publish={'true' if publish else 'false'}") - print(f"reason={'current version is newer than published version' if publish else 'published version is current or newer'}") + if publish: + reason = "current version is newer than published version" + elif reason == "current version is newer than published version": + reason = "published version is current or newer" + print(f"reason={reason}") PY - name: Publish From 439e99a6c16ac90030f2c409d64b5a50449ed429 Mon Sep 17 00:00:00 2001 From: javorosas Date: Tue, 7 Apr 2026 22:19:48 +0200 Subject: [PATCH 7/7] ci: tighten publish gate and sync releases with tags --- .github/workflows/publish.yml | 139 ++++++++++++++++++++++++++-------- 1 file changed, 108 insertions(+), 31 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index fb50c7b..1117dd5 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -7,7 +7,7 @@ on: workflow_dispatch: permissions: - contents: read + contents: write jobs: publish: @@ -22,6 +22,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Setup Java uses: actions/setup-java@v4 @@ -41,6 +43,8 @@ jobs: run: | set -euo pipefail + python3 -m pip install --disable-pip-version-check --quiet semver + python3 <<'PY' >> "$GITHUB_OUTPUT" import base64 import os @@ -48,6 +52,7 @@ jobs: import urllib.request import urllib.error import xml.etree.ElementTree as ET + import semver ns = {"m": "http://maven.apache.org/POM/4.0.0"} pom = ET.parse("pom.xml").getroot() @@ -72,28 +77,22 @@ jobs: except Exception: latest = "0.0.0" - def parse_semver(value: str): - core = value.split("+", 1)[0] - main, _, prerelease = core.partition("-") - major, minor, patch = (int(part) for part in main.split(".")[:3]) - return major, minor, patch, prerelease - - def is_greater(left: str, right: str) -> bool: - left_parts = parse_semver(left) - right_parts = parse_semver(right) - if left_parts[:3] != right_parts[:3]: - return left_parts[:3] > right_parts[:3] - left_pre = left_parts[3] - right_pre = right_parts[3] - if left_pre == right_pre: - return False - if not left_pre: - return True - if not right_pre: - return False - return left_pre > right_pre - - publish = is_greater(version, latest) + def to_semver(value: str): + try: + return semver.Version.parse(value) + except ValueError: + return None + + current_semver = to_semver(version) + latest_semver = to_semver(latest) or semver.Version.parse("0.0.0") + if current_semver is None: + print(f"current_version={version}") + print(f"latest_version={latest}") + print("publish=false") + print("reason=current version is not valid semver") + sys.exit(0) + + publish = current_semver > latest_semver reason = "current version is newer than published version" if publish: # If a deployment for this exact version is already staged/ongoing in the Portal, @@ -133,6 +132,82 @@ jobs: if: steps.gate.outputs.publish == 'true' run: mvn -B -ntp -DskipTests -DpublishRelease=true deploy + - name: Ensure release tag is in sync + if: steps.gate.outputs.publish == 'true' && steps.publish_step.outcome == 'success' + env: + VERSION: ${{ steps.gate.outputs.current_version }} + shell: bash + run: | + set -euo pipefail + tag="v${VERSION}" + current_sha="$(git rev-parse HEAD)" + + if git rev-parse "$tag" >/dev/null 2>&1; then + tag_sha="$(git rev-list -n 1 "$tag")" + if [ "$tag_sha" != "$current_sha" ]; then + echo "::error::Tag $tag already exists at $tag_sha, expected $current_sha" + exit 1 + fi + echo "Tag $tag already in sync with $current_sha" + exit 0 + fi + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag -a "$tag" -m "Release $tag" + git push origin "$tag" + echo "Created and pushed tag $tag" + + - name: Create or update GitHub release notes + if: steps.gate.outputs.publish == 'true' && steps.publish_step.outcome == 'success' + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.gate.outputs.current_version }} + shell: bash + run: | + set -euo pipefail + tag="v${VERSION}" + notes_file="/tmp/release_notes.md" + + python3 - <<'PY' + import os + from pathlib import Path + + version = os.environ["VERSION"] + header = f"## [{version}]" + changelog = Path("CHANGELOG.md") + output = Path("/tmp/release_notes.md") + + if not changelog.exists(): + output.write_text(f"Release {version}", encoding="utf-8") + raise SystemExit(0) + + lines = changelog.read_text(encoding="utf-8").splitlines() + in_target = False + captured = [] + for line in lines: + if line.startswith(header): + in_target = True + continue + if in_target and line.startswith("## ["): + break + if in_target: + captured.append(line) + + notes = "\n".join(captured).strip() + if not notes: + notes = f"Release {version}" + output.write_text(notes + "\n", encoding="utf-8") + PY + + if gh release view "$tag" >/dev/null 2>&1; then + gh release edit "$tag" --title "$tag" --notes-file "$notes_file" + echo "Updated release $tag" + else + gh release create "$tag" --title "$tag" --notes-file "$notes_file" + echo "Created release $tag" + fi + - name: Notify Slack (success) if: steps.gate.outputs.publish == 'true' && steps.publish_step.outcome == 'success' env: @@ -161,29 +236,30 @@ jobs: changelog = Path("CHANGELOG.md") latest_changes = "See CHANGELOG for details." + section_header = f"## [{version}]" if changelog.exists(): lines = changelog.read_text(encoding="utf-8").splitlines() - in_latest = False + in_target = False bullets = [] for line in lines: - if line.startswith("## [") and not in_latest: - in_latest = True + if line.startswith(section_header): + in_target = True continue - if in_latest and line.startswith("## ["): + if in_target and line.startswith("## ["): break - if in_latest and line.startswith("- "): + if in_target and line.startswith("- "): bullets.append(line[2:].strip()) if bullets: latest_changes = "\n".join(f"• {b}" for b in bullets[:5]) payload = { - "text": f"🚀 Facturapi Java SDK {version} published", + "text": f"🚀 Facturapi Java SDK {version} validated and submitted", "blocks": [ { "type": "header", "text": { "type": "plain_text", - "text": f"🚀 Java SDK {version} is live on Maven Central" + "text": f"🚀 Java SDK {version} validated and submitted to Maven Central" } }, { @@ -202,8 +278,9 @@ jobs: "text": ( "*Useful links*\n" f"• Maven Central: \n" + f"• Central deployment status: \n" f"• Workflow run: <{server_url}/{repo}/actions/runs/{run_id}|Open run>\n" - f"• Changelog: <{server_url}/{repo}/blob/main/CHANGELOG.md|Read changes>" + f"• Changelog: <{server_url}/{repo}/blob/{sha}/CHANGELOG.md|Read changes>" ) } },