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
| Workflow | File | Triggers | What it does |
|---|---|---|---|
| CI | test.yml | Push/PR to main or release | Lint, test, build, contract test, SonarQube |
| Deploy Dev | deploy.yml | Push to main (rust/**) | Build + deploy core-api to Cloud Run dev |
| Management Plane | management-plane.yml | Push to main (rust/management-plane/**) | Lint, test, build, deploy mgmt plane to dev |
| Deploy Docs (Staging) | docs-deploy.yml | Push to main (apps/docs/**) | Build + deploy docs to Firebase dev |
| Deploy Internal Docs (Staging) | internal-docs-deploy.yml | Push to main (apps/docs-internal/**) | Build + deploy internal docs to Firebase dev |
| Terraform | terraform.yml | Push to main (infrastructure/**) | Apply dev infra; plan prod on PR |
| Gitleaks | gitleaks.yml | Push/PR to main or release | Secret scanning |
| ZAP | zap.yml | After Deploy Dev, weekly, manual | OWASP baseline scan against dev API |
| Sandbox Validation | sandbox-validate.yml | Manual only | Validate golden files against tax authority sandboxes (Rust) |
| Cross-Compile FFI | cross-compile.yml | Push/PR to main or release (rust/ffi/**, rust/core/**, android/**) | Build FFI crate for 12 targets + assemble iOS XCFramework + Android AAR |
| Python Wheel | python-wheel.yml | Push/PR to main or release (python/**, rust/ffi/**, rust/core/**) | Build platform wheels via cross-compile, test (publishing handled by sdk-release) |
| Release | release.yml | Push/PR to release | Full prod deploy (canary strategy) |
| SDK Release | sdk-release.yml | Push to main (SDK paths), v* tags, manual | Dual-track SDK publishing: dev packages on main, stable on tags. GitHub Release with all SDK artifacts |
How to Release to Production
-
Create a PR from
maintoreleasegh pr create --base release --head main --title "Release: v0.X.Y" -
Review the PR -- CI runs automatically plus:
- Terraform plan for prod (posted as PR comment)
- Rust build dry-run
-
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
-
Tag the release (after deploy succeeds):
git tag v0.X.Y && git push origin v0.X.YThis triggers the SDK Release Pipeline (
sdk-release.yml) which:- Builds all 12 FFI cross-compile targets
- Publishes SDK packages to production registries:
- NuGet:
Zyntem.Fiscalizationon nuget.org - npm:
@zyntem/fiscalon npmjs.com - PyPI:
zyntem-fiscalon pypi.org (trusted publishing) - Maven:
com.zyntem.fiscalization:fiscalization-sdkon GitHub Packages - Swift:
FiscalizationFFI.xcframeworkattached to GitHub Release
- NuGet:
- 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:
| Package | Dev Version Format | Registry |
|---|---|---|
| NuGet | 0.1.0-dev.abc1234 | GitHub Packages |
| npm | 0.1.0-dev.abc1234 (--tag dev) | npmjs.com |
| Python | 0.1.0.dev20260322153000 (PEP 440) | CI artifacts only |
| Android | 0.1.0-dev.abc1234 | GitHub 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.
| Purpose | WIF Provider | Service Account |
|---|---|---|
| Dev deploy | GCP_WORKLOAD_IDENTITY_PROVIDER | GCP_DEPLOY_SERVICE_ACCOUNT |
| Prod deploy | GCP_WORKLOAD_IDENTITY_PROVIDER_PROD | GCP_DEPLOY_SERVICE_ACCOUNT |
| Terraform | GCP_WORKLOAD_IDENTITY_PROVIDER | GCP_TERRAFORM_SERVICE_ACCOUNT |
| Firebase (dev) | GCP_WORKLOAD_IDENTITY_PROVIDER | GCP_FIREBASE_DEPLOY_SA_DEV |
| Firebase (prod) | GCP_WORKLOAD_IDENTITY_PROVIDER_PROD | GCP_FIREBASE_DEPLOY_SA_PROD |
Self-Hosted Runners
Runs on org-level self-hosted runners (macOS). Key constraints:
- Use
self-hostednotubuntu-latest - No
apt-get-- usebrewif needed - Disable cloud cache:
package-manager-cache: falseonsetup-node sed -ineedssed -i'' -efor macOS compat- Toolcache:
/Users/runner/hostedtoolcachemust 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 (python3from Homebrew) directly. The setup step creates apython→python3symlink since Homebrew only shipspython3. Pass--break-system-packagesto 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_HOMEis 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
| Name | Rust Target | Build Tool | Output |
|---|---|---|---|
| linux-x86_64 | x86_64-unknown-linux-gnu | cargo-zigbuild | .so + .a |
| linux-arm64 | aarch64-unknown-linux-gnu | cargo-zigbuild | .so + .a |
| macos-x86_64 | x86_64-apple-darwin | cargo (native) | .dylib + .a |
| macos-arm64 | aarch64-apple-darwin | cargo (native) | .dylib + .a |
| windows-x86_64 | x86_64-pc-windows-gnu | cargo-zigbuild | .dll + .a |
| windows-arm64 | aarch64-pc-windows-gnullvm | cargo-zigbuild | .dll + .a |
| ios-arm64 | aarch64-apple-ios | cargo (Xcode SDK) | .a |
| ios-arm64-sim | aarch64-apple-ios-sim | cargo (Xcode SDK) | .a |
| ios-x86_64-sim | x86_64-apple-ios | cargo (Xcode SDK) | .a |
| android-arm64 | aarch64-linux-android | cargo-ndk (API 21) | .so |
| android-armv7 | armv7-linux-androideabi | cargo-ndk (API 21) | .so |
| wasm | wasm32-unknown-unknown | cargo | .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-iosstatic library - Simulator (
ios-arm64-sim+ios-x86_64-sim): universal fat library vialipo
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.gzfiscalization-ffi-{VERSION}-linux-arm64.tar.gzfiscalization-ffi-{VERSION}-macos-x86_64.tar.gzfiscalization-ffi-{VERSION}-macos-arm64.tar.gzfiscalization-ffi-{VERSION}-macos-universal.tar.gz(fat binary vialipo)
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 buildwithrustuptargets.
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 Tag | Source Artifact |
|---|---|
manylinux_2_17_x86_64 | fiscalization-ffi-linux-x86_64 |
manylinux_2_17_aarch64 | fiscalization-ffi-linux-arm64 |
macosx_11_0_x86_64 | fiscalization-ffi-macos-x86_64 |
macosx_11_0_arm64 | fiscalization-ffi-macos-arm64 |
win_amd64 | fiscalization-ffi-windows-x86_64 |
win_arm64 | fiscalization-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-v8aandarmeabi-v7a - Kotlin wrapper (
com.zyntem.fiscalization.FiscalEngine)
Build Flow
- Cross-compile matrix builds
fiscalization-ffifor both Android targets viacargo-ndk android-aarjob downloads the.soartifacts (no duplicate compilation)- Stage
.sofiles intoandroid/fiscalization-sdk/src/main/jniLibs/ - Run Gradle
assembleReleaseto produce the AAR - 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
| Event | Track | Version | Registries |
|---|---|---|---|
Push tag v* | Stable | From 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 dispatch | Configurable | User-provided or dev | Depends 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
| Secret | Purpose | Required for |
|---|---|---|
NUGET_API_KEY | Publish to nuget.org | Stable NuGet releases |
NPM_TOKEN | Publish to npmjs.com | Stable npm releases only |
GITHUB_TOKEN | GitHub Packages, GHCR, releases | Always (auto-provided) |
| PyPI trusted publishing | OIDC via pypi environment | Stable 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
initandprocess_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
| Action | Version | Used in |
|---|---|---|
actions/checkout | v6 | All workflows |
actions/upload-artifact | v7 | cross-compile, test |
actions/download-artifact | v8 | cross-compile, test, python-wheel, sdk-release |
actions/setup-java | v4 | cross-compile |
actions/setup-dotnet | v5 | sdk-release |
docker/login-action | v4 | release, management-plane, sdk-release |
gradle/actions/setup-gradle | v5 | cross-compile, sdk-release |
dtolnay/rust-toolchain | stable | cross-compile, test |
SonarSource/sonarqube-scan-action | v7 | test |
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)