diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ce85f08..1117dd5 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -4,9 +4,10 @@ on: push: branches: - main + workflow_dispatch: permissions: - contents: read + contents: write jobs: publish: @@ -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 @@ -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() @@ -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: \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/{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: | 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. 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