Skip to main content

CI/CD Pipeline

Branch Strategy

main      <- development (auto-deploys to dev)
release <- production (PR required, human approval)
v* tags <- GitHub Releases (binaries, GHCR images)

Key rule: Production deploys ONLY happen through the release branch. Pushing to main deploys to dev/staging only. Never bypass this.

Pipeline Overview

Push to main:
|- CI (lint -> test -> build -> contract test)
|- Cross-Compile FFI (12-target matrix)
| +- Python Wheel (6 platform wheels, test, publish on release)
|- Deploy Core API to dev
|- Deploy Management Plane to dev
|- Deploy Docs to staging (Firebase)
|- Deploy Internal Docs to staging (Firebase)
|- Terraform Apply (dev)
|- Gitleaks scan
+- ZAP security scan (post-deploy)

PR to release:
+- CI + Terraform Plan (prod) + Rust build dry-run

Merge to release:
+- CI
+- Deploy Core API to prod (canary 0->10->50->100%)
|- Deploy Management Plane to prod
|- Deploy Docs to prod (Firebase)
+- Deploy Internal Docs to prod (Firebase)

Push tag v*:
+- CI + Cross-Compile FFI (all 12 targets)
+- SDK Release Pipeline:
|- Distribution tarballs (macOS universal via lipo)
|- NuGet package -> nuget.org
|- npm WASM package -> npmjs.com
|- Python wheels (6 platforms) -> PyPI
|- Android AAR -> GitHub Packages
|- Swift XCFramework -> GitHub Release asset
+- GitHub Release (all artifacts + checksums + changelog + GHCR + Trivy)
+- Post-publish smoke test (Python, npm, .NET)

Push to main (SDK paths):
+- CI + Cross-Compile FFI
+- SDK Release Pipeline (dev track):
|- NuGet package (dev version) -> GitHub Packages
|- npm WASM package (--tag dev) -> npmjs.com
|- Python wheels (dev version) -> CI artifacts
|- Android AAR (dev version) -> GitHub Packages
|- Post-publish smoke test
+- Rolling dev release (all artifacts -> GitHub Release "dev")

Workflows

WorkflowFileTriggersWhat it does
CItest.ymlPush/PR to main or releaseLint, test, build, contract test, SonarQube
Deploy Devdeploy.ymlPush to main (rust/**)Build + deploy core-api to Cloud Run dev
Management Planemanagement-plane.ymlPush to main (rust/management-plane/**)Lint, test, build, deploy mgmt plane to dev
Deploy Docs (Staging)docs-deploy.ymlPush to main (apps/docs/**)Build + deploy docs to Firebase dev
Deploy Internal Docs (Staging)internal-docs-deploy.ymlPush to main (apps/docs-internal/**)Build + deploy internal docs to Firebase dev
Terraformterraform.ymlPush to main (infrastructure/**)Apply dev infra; plan prod on PR
Gitleaksgitleaks.ymlPush/PR to main or releaseSecret scanning
ZAPzap.ymlAfter Deploy Dev, weekly, manualOWASP baseline scan against dev API
Sandbox Validationsandbox-validate.ymlManual onlyValidate golden files against tax authority sandboxes (Rust)
Cross-Compile FFIcross-compile.ymlPush/PR to main or release (rust/ffi/**, rust/core/**, android/**)Build FFI crate for 12 targets + assemble iOS XCFramework + Android AAR
Python Wheelpython-wheel.ymlPush/PR to main or release (python/**, rust/ffi/**, rust/core/**)Build platform wheels via cross-compile, test (publishing handled by sdk-release)
Releaserelease.ymlPush/PR to releaseFull prod deploy (canary strategy)
SDK Releasesdk-release.ymlPush to main (SDK paths), v* tags, manualDual-track SDK publishing: dev packages on main, stable on tags. GitHub Release with all SDK artifacts

How to Release to Production

  1. Create a PR from main to release

    gh pr create --base release --head main --title "Release: v0.X.Y"
  2. Review the PR -- CI runs automatically plus:

    • Terraform plan for prod (posted as PR comment)
    • Rust build dry-run
  3. Merge the PR -- this triggers the prod deploy:

    • Terraform apply (prod infrastructure)
    • Core API: canary deploy (0% -> 10% -> 50% -> 100%) with health checks
    • Management plane: canary deploy with health check
    • Docs + internal docs: Firebase deploy to prod
    • Auto-rollback on any failure
  4. Tag the release (after deploy succeeds):

    git tag v0.X.Y && git push origin v0.X.Y

    This triggers the SDK Release Pipeline (sdk-release.yml) which:

    • Builds all 12 FFI cross-compile targets
    • Publishes SDK packages to production registries:
      • NuGet: Zyntem.Fiscalization on nuget.org
      • npm: @zyntem/fiscal on npmjs.com
      • PyPI: zyntem-fiscal on pypi.org (trusted publishing)
      • Maven: com.zyntem.fiscalization:fiscalization-sdk on GitHub Packages
      • Swift: FiscalizationFFI.xcframework attached to GitHub Release
    • Creates a GitHub Release with all artifacts + SHA256SUMS
    • GHCR Docker images (tagged: vX.Y.Z, vX.Y, latest) + Trivy scan
    • Runs post-publish smoke tests (Python, npm, .NET)

Dev Packages (Pre-release)

Every push to main (SDK paths) creates a rolling dev GitHub Release with all SDK artifacts attached. This release is overwritten on each push — always points to the latest build from main.

Stable URL: https://github.com/javipelopi-dev/fiscalization/releases/tag/dev

Individual CI artifacts (retained 30 days) are still available on each workflow run for debugging specific builds.

Pushes to main that modify SDK-related paths also publish dev packages to registries:

PackageDev Version FormatRegistry
NuGet0.1.0-dev.abc1234GitHub Packages
npm0.1.0-dev.abc1234 (--tag dev)npmjs.com
Python0.1.0.dev20260322153000 (PEP 440)CI artifacts only
Android0.1.0-dev.abc1234GitHub Packages

Install dev packages:

# npm
npm install @zyntem/fiscal@dev

# NuGet (add GitHub Packages source first)
dotnet add package Zyntem.Fiscalization --version 0.1.0-dev.abc1234 --source github

# Android (already uses GitHub Packages)
implementation("com.zyntem.fiscalization:fiscalization-sdk:0.1.0-dev.abc1234")

Authentication

All CI/CD uses Workload Identity Federation (keyless). No service account JSON keys.

PurposeWIF ProviderService Account
Dev deployGCP_WORKLOAD_IDENTITY_PROVIDERGCP_DEPLOY_SERVICE_ACCOUNT
Prod deployGCP_WORKLOAD_IDENTITY_PROVIDER_PRODGCP_DEPLOY_SERVICE_ACCOUNT
TerraformGCP_WORKLOAD_IDENTITY_PROVIDERGCP_TERRAFORM_SERVICE_ACCOUNT
Firebase (dev)GCP_WORKLOAD_IDENTITY_PROVIDERGCP_FIREBASE_DEPLOY_SA_DEV
Firebase (prod)GCP_WORKLOAD_IDENTITY_PROVIDER_PRODGCP_FIREBASE_DEPLOY_SA_PROD

Self-Hosted Runners

Runs on org-level self-hosted runners (macOS). Key constraints:

  • Use self-hosted not ubuntu-latest
  • No apt-get -- use brew if needed
  • Disable cloud cache: package-manager-cache: false on setup-node
  • sed -i needs sed -i'' -e for macOS compat
  • Toolcache: /Users/runner/hostedtoolcache must exist and be writable by the runner user. setup-* actions (node, java, dotnet) use a pre-built install script that hardcodes this path. One-time setup: sudo mkdir -p /Users/runner/hostedtoolcache && sudo chown -R $(whoami):staff /Users/runner
  • Python: Do NOT use actions/setup-python — it hangs on self-hosted runners trying to download Python from the cloud (15min timeout). Use system Python (python3 from Homebrew) directly. The setup step creates a pythonpython3 symlink since Homebrew only ships python3. Pass --break-system-packages to pip on newer Python versions.
  • sccache: A single sccache server is started by Start Runners.command (idle_timeout=0, config at ~/.config/sccache/config). Workflows reuse it rather than starting their own — prevents connection drops under heavy concurrent load. If the server isn't running, workflows fall back to starting one.
  • Android SDK: ANDROID_HOME is set dynamically in jobs that need it (cross-compile AAR, sdk-release AAR). The workflow installs commandline-tools and NDK via Homebrew if missing.
  • Version override: The SDK Release Pipeline accepts an optional version input for workflow_dispatch. Must be valid semver (e.g. 0.2.0-rc.1) or left empty for auto-generated dev versions.

Cross-Compilation (FFI)

The cross-compile.yml workflow builds the fiscalization-ffi crate for 12 targets using a matrix strategy, then assembles an iOS XCFramework. All builds run on self-hosted macOS runners.

Targets

NameRust TargetBuild ToolOutput
linux-x86_64x86_64-unknown-linux-gnucargo-zigbuild.so + .a
linux-arm64aarch64-unknown-linux-gnucargo-zigbuild.so + .a
macos-x86_64x86_64-apple-darwincargo (native).dylib + .a
macos-arm64aarch64-apple-darwincargo (native).dylib + .a
windows-x86_64x86_64-pc-windows-gnucargo-zigbuild.dll + .a
windows-arm64aarch64-pc-windows-gnullvmcargo-zigbuild.dll + .a
ios-arm64aarch64-apple-ioscargo (Xcode SDK).a
ios-arm64-simaarch64-apple-ios-simcargo (Xcode SDK).a
ios-x86_64-simx86_64-apple-ioscargo (Xcode SDK).a
android-arm64aarch64-linux-androidcargo-ndk (API 21).so
android-armv7armv7-linux-androideabicargo-ndk (API 21).so
wasmwasm32-unknown-unknowncargo.wasm

iOS XCFramework

After the matrix build, two assembly jobs run in parallel:

iOS XCFramework

The xcframework job assembles FiscalizationFFI.xcframework with two slices:

  • Device (ios-arm64): aarch64-apple-ios static library
  • Simulator (ios-arm64-sim + ios-x86_64-sim): universal fat library via lipo

The xcframework includes the C header and a module map so it can be consumed as a binary target by the FiscalizationSDK Swift Package (in swift/FiscalizationSDK/).

Artifact: FiscalizationFFI-xcframework (retained 30 days).

Local build: ./scripts/build-xcframework.sh [--release|--debug]

Android AAR

The android-aar job downloads the Android .so artifacts from the matrix build and assembles them into a Gradle AAR package. No duplicate Rust compilation — it reuses the same artifacts built by the android-arm64 and android-armv7 matrix entries.

Artifact: fiscalization-sdk-aar (cross-compile), fiscalization-sdk-aar-release (sdk-release). Retained 30 days.

Artifact Naming

Artifacts are uploaded as fiscalization-ffi-{name} (e.g., fiscalization-ffi-linux-x86_64). Each artifact contains the platform library files plus fiscalization.h (C header generated by cbindgen).

Distribution Tarballs

After all targets build, a package job creates distribution tarballs for Linux and macOS:

  • fiscalization-ffi-{VERSION}-linux-x86_64.tar.gz
  • fiscalization-ffi-{VERSION}-linux-arm64.tar.gz
  • fiscalization-ffi-{VERSION}-macos-x86_64.tar.gz
  • fiscalization-ffi-{VERSION}-macos-arm64.tar.gz
  • fiscalization-ffi-{VERSION}-macos-universal.tar.gz (fat binary via lipo)

Each tarball contains:

lib/libfiscalization_ffi.{so,dylib,a}    # shared and/or static library
lib/pkgconfig/fiscalization.pc # pkg-config metadata
include/fiscalization.h # C/C++ header
examples/example.c # C usage example
examples/Makefile # builds the example (uses pkg-config)

The local packaging script is at rust/ffi/dist/package.sh for offline use.

Cross-Compilation Tools

  • cargo-zigbuild: Uses Zig as a C cross-compiler for Linux and Windows targets from macOS. Requires zig (installed via Homebrew).
  • cargo-ndk: Builds for Android using the Android NDK. Targets API level 21 (Android 5.0). Requires ANDROID_NDK_HOME (installed via Homebrew cask).
  • Native cargo: macOS, iOS, and WASM targets use standard cargo build with rustup targets.

Runner Requirements

Self-hosted macOS runners need: Homebrew, Xcode (for iOS SDK), and optionally pre-installed zig, android-ndk, cargo-zigbuild, cargo-ndk (workflow installs them if missing).

Python Wheel Pipeline

The python-wheel.yml workflow produces platform-specific wheels for the zyntem-fiscal PyPI package. It reuses the cross-compile workflow to build native libraries, then packages each into a wheel via python/build_wheel.py.

Platforms

Wheel Platform TagSource Artifact
manylinux_2_17_x86_64fiscalization-ffi-linux-x86_64
manylinux_2_17_aarch64fiscalization-ffi-linux-arm64
macosx_11_0_x86_64fiscalization-ffi-macos-x86_64
macosx_11_0_arm64fiscalization-ffi-macos-arm64
win_amd64fiscalization-ffi-windows-x86_64
win_arm64fiscalization-ffi-windows-arm64

Publishing

Wheels are published to PyPI only on merges to the release branch, using PyPI trusted publishing (OIDC, no API tokens). The pypi GitHub environment must be configured with the PyPI project.

Android AAR

The android-aar job (inside cross-compile.yml) produces a fiscalization-sdk.aar containing:

  • JNI shared libraries for arm64-v8a and armeabi-v7a
  • Kotlin wrapper (com.zyntem.fiscalization.FiscalEngine)

Build Flow

  1. Cross-compile matrix builds fiscalization-ffi for both Android targets via cargo-ndk
  2. android-aar job downloads the .so artifacts (no duplicate compilation)
  3. Stage .so files into android/fiscalization-sdk/src/main/jniLibs/
  4. Run Gradle assembleRelease to produce the AAR
  5. Upload AAR as CI artifact

Publishing

On merge to release, the workflow publishes the AAR to GitHub Packages:

com.zyntem.fiscalization:fiscalization-sdk:0.1.0

Consumers add to their build.gradle.kts:

repositories {
maven {
url = uri("https://maven.pkg.github.com/javipelopi-dev/fiscalization")
credentials { /* GitHub token */ }
}
}
dependencies {
implementation("com.zyntem.fiscalization:fiscalization-sdk:0.1.0")
}

Local Build

cd android && ./build-aar.sh --release

Requires: cargo-ndk, ANDROID_NDK_HOME, Rust Android targets installed.

SDK Release Pipeline

The sdk-release.yml workflow manages all SDK packaging and publishing across two tracks:

Triggers

EventTrackVersionRegistries
Push tag v*StableFrom tag (e.g. 1.0.0)nuget.org, npmjs, PyPI, GitHub Packages, GitHub Release
Push to main (SDK paths)Dev{base}-dev.{sha}GitHub Packages (NuGet, Maven), GitHub Release (rolling dev)
Manual dispatchConfigurableUser-provided or devDepends on version format

Job DAG

version ─┐
ci ──────┼──> dist-tarballs ──┐
cross- │──> nuget ──────────┤
compile ─┤──> pypi ──> pypi- │──> github-release (stable+tag only)
│ publish ──┤
│──> maven ──────────┤
│──> swift ──────────┘
└──> npm-wasm ───────┤
└──> smoke-test

Secrets Required

SecretPurposeRequired for
NUGET_API_KEYPublish to nuget.orgStable NuGet releases
NPM_TOKENPublish to npmjs.comStable npm releases only
GITHUB_TOKENGitHub Packages, GHCR, releasesAlways (auto-provided)
PyPI trusted publishingOIDC via pypi environmentStable PyPI releases

Post-Publish Smoke Tests

After all packages are built, smoke tests verify each SDK by installing from the build artifacts (not from registries, to avoid indexing delays):

  • Python: install wheel, import init and process_transaction
  • npm: install package, dynamic import (ESM)
  • .NET: create console project, add NuGet package from local path, build

Prod Deploy Details

Core API Canary Strategy

Deploy with 0% traffic (--no-traffic --tag canary)
-> Health check canary endpoint
-> Shift to 10% -> soak 5 min -> health check
-> Shift to 50% -> soak 5 min -> health check
-> Shift to 100% -> final health check
-> On ANY failure: auto-rollback to previous revision

Rollback

Automatic on deploy failure. Manual rollback:

gcloud run services update-traffic fiscalapi-core-prod \
--region europe-west1 \
--to-revisions=LATEST=0 --clear-tags

Action Versions

ActionVersionUsed in
actions/checkoutv6All workflows
actions/upload-artifactv7cross-compile, test
actions/download-artifactv8cross-compile, test, python-wheel, sdk-release
actions/setup-javav4cross-compile
actions/setup-dotnetv5sdk-release
docker/login-actionv4release, management-plane, sdk-release
gradle/actions/setup-gradlev5cross-compile, sdk-release
dtolnay/rust-toolchainstablecross-compile, test
SonarSource/sonarqube-scan-actionv7test

Concurrency

Cross-compile uses cross-compile-${{ github.workflow }}-${{ github.ref }} as its concurrency group. This prevents the standalone run and the one embedded in SDK Release Pipeline from cancelling each other, while still cancelling stale runs within each workflow.

Local Testing

make test              # Run all Rust workspace tests
make lint # Fmt, clippy, audit, deny
make build # Build fi-api server (release)