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
293 changes: 269 additions & 24 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ on:
push:
branches:
- main
workflow_dispatch:

permissions:
contents: read
contents: write

jobs:
publish:
Expand All @@ -16,9 +17,13 @@ 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
with:
fetch-depth: 0

- name: Setup Java
uses: actions/setup-java@v4
Expand All @@ -38,10 +43,16 @@ jobs:
run: |
set -euo pipefail

python3 -m pip install --disable-pip-version-check --quiet semver

python3 <<'PY' >> "$GITHUB_OUTPUT"
import base64
import os
import sys
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()
Expand All @@ -66,38 +77,272 @@ 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,
# 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
id: publish_step
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:
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."
section_header = f"## [{version}]"
if changelog.exists():
lines = changelog.read_text(encoding="utf-8").splitlines()
in_target = False
bullets = []
for line in lines:
if line.startswith(section_header):
in_target = True
continue
if in_target and line.startswith("## ["):
break
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} validated and submitted",
"blocks": [
{
"type": "header",
"text": {
"type": "plain_text",
"text": f"🚀 Java SDK {version} validated and submitted to 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: <https://central.sonatype.com/artifact/io.facturapi/facturapi-java/{version}|View artifact>\n"
f"• Central deployment status: <https://central.sonatype.com/publishing/deployments|Check progress>\n"
f"• Workflow run: <{server_url}/{repo}/actions/runs/{run_id}|Open run>\n"
f"• Changelog: <{server_url}/{repo}/blob/{sha}/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" || true

- name: Notify Slack (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 }}
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" || true

- name: Skip publish
if: steps.gate.outputs.publish != 'true'
run: |
Expand Down
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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 <apiKey>`.
- 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.
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@
<configuration>
<publishingServerId>central</publishingServerId>
<autoPublish>true</autoPublish>
<waitUntil>published</waitUntil>
<waitUntil>validated</waitUntil>
</configuration>
</plugin>
<plugin>
Expand Down
Loading