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
25 changes: 25 additions & 0 deletions .cursor/skills/ode-synk-bundle-publish/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,31 @@ description: >-
3. Store **API URLs and tokens** in CI secrets; never embed production credentials in source.
4. For bundle structure reminders, see **[CONTEXT_BUNDLE_AND_CI.md](https://github.com/OpenDataEnsemble/custom_app/blob/main/CONTEXT_BUNDLE_AND_CI.md)** in **custom_app** (summary only).

## CI / GitHub Actions (recommended pattern)

Do **not** build synk from source in app repos. Pin an ODE release:

```yaml
env:
SYNK_CLI_VERSION: v1.1.2 # bump intentionally

- name: Install synk
run: |
curl -fsSL "https://github.com/OpenDataEnsemble/ode/releases/download/${SYNK_CLI_VERSION}/synkronus-cli-linux-amd64.tar.gz" \
| tar -xz -C /tmp
install -m 0755 /tmp/synkronus-cli-linux-amd64 /usr/local/bin/synk
```

Deploy steps:

```bash
synk --config "$CONFIG_FILE" login -u "$USER" --password "$PASS"
synk --config "$CONFIG_FILE" app-bundle upload bundle-v1.0.0.zip
synk --config "$CONFIG_FILE" app-bundle switch "$VERSION"
```

Alternative: `ghcr.io/opendataensemble/synkronus-cli:vX.Y.Z` for Docker-based local tooling.

## Related

- [App bundles (using)](https://opendataensemble.org/docs/using/app-bundles)
Expand Down
26 changes: 26 additions & 0 deletions .github/CICD.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,32 @@ The workflow requires these permissions:

- `GITHUB_TOKEN` - Automatically provided by GitHub Actions

### Synkronus CLI Docker Build & Publish

**Workflow File**: `.github/workflows/synkronus-cli-docker.yml`

#### Triggers

- **Push to `main` / `dev`** when `synkronus-cli/**` or `Dockerfile.cli.slim` changes
- **Pull Requests** — build only, no publish
- **Release published** — versioned tags
- **Manual dispatch**

#### Image Registry

- Image: `ghcr.io/opendataensemble/synkronus-cli`
- Dockerfile: `Dockerfile.cli.slim` (pre-built static `synk` binary, Alpine 3.23)

Tagging follows the same strategy as the Synkronus server image (`latest`, `dev`, `main`, `v{X.Y.Z}`, `sha-{short}`, etc.).

#### CLI binaries (primary install path)

**Workflow File**: `.github/workflows/synkronus-cli.yml`

Release assets: `synkronus-cli-{os}-{arch}.tar.gz` attached to GitHub Releases. Custom app CI should pin a release tag via `SYNK_CLI_VERSION` and download `synkronus-cli-linux-amd64.tar.gz`.

Non-interactive CI login: `synk login -u USER --password "$SYNK_PASSWORD"` (v1.1.2+).

### Formulus Android Build

**Workflow File**: `.github/workflows/formulus-android.yml`
Expand Down
290 changes: 290 additions & 0 deletions .github/workflows/synkronus-cli-docker.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,290 @@
name: Synkronus CLI Docker Build & Publish

on:
push:
branches:
- main
- dev
paths:
- 'synkronus-cli/**'
- 'Dockerfile.cli.slim'
- '.github/workflows/synkronus-cli-docker.yml'
pull_request:
paths:
- 'synkronus-cli/**'
- 'Dockerfile.cli.slim'
- '.github/workflows/synkronus-cli-docker.yml'
workflow_dispatch:
release:
types: [published]

env:
REGISTRY: ghcr.io
IMAGE_NAME: opendataensemble/synkronus-cli
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true

jobs:
build:
runs-on: ubuntu-latest
name: Cross-compile synkronus-cli
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Determine CLI version from git
id: version
run: |
if [ "${{ github.event_name }}" == "release" ]; then
VERSION="${{ github.event.release.tag_name }}"
else
VERSION=$(git describe --tags --abbrev=0 2>/dev/null || echo "1.0.0")
fi
echo "version=${VERSION}" >> $GITHUB_OUTPUT
echo "Building synkronus-cli with version: ${VERSION}"

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.25.x'
cache-dependency-path: synkronus-cli/go.sum

- name: Build synkronus-cli binaries
working-directory: synkronus-cli
env:
CLI_VERSION: ${{ steps.version.outputs.version }}
run: |
set -euo pipefail
ROOT="${GITHUB_WORKSPACE}"
LD_FLAGS="-w -s -X github.com/OpenDataEnsemble/ode/synkronus-cli/internal/cmd.Version=${CLI_VERSION#v}"
mkdir -p "${ROOT}/docker-dist"
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="${LD_FLAGS}" -o "${ROOT}/docker-dist/synk-linux-amd64" ./cmd/synkronus
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="${LD_FLAGS}" -o "${ROOT}/docker-dist/synk-linux-arm64" ./cmd/synkronus

- name: Upload docker build context artifact
uses: actions/upload-artifact@v4
with:
name: synkronus-cli-docker-dist
path: docker-dist/
if-no-files-found: error

image-amd64:
needs: build
runs-on: ubuntu-latest
name: Build linux/amd64 image
permissions:
contents: read
packages: write
outputs:
digest: ${{ steps.push.outputs.digest }}
steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Download docker build context
uses: actions/download-artifact@v4
with:
name: synkronus-cli-docker-dist
path: docker-dist/

- name: Stage build context for Dockerfile.cli.slim
run: |
set -euo pipefail
mkdir -p docker-context
cp docker-dist/synk-linux-amd64 docker-context/synk
chmod +x docker-context/synk

- name: Log in to Github Container Registry
if: github.event_name != 'pull_request'
uses: redhat-actions/podman-login@v1
with:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
registry: ${{ env.REGISTRY }}

- name: Compute image metadata (scratch tag)
id: meta
uses: docker/metadata-action@v6
with:
images: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=tmp-${{ github.run_id }}-amd64

- name: Build image with Buildah
id: build-image
uses: redhat-actions/buildah-build@v2
with:
image: ${{ env.IMAGE_NAME }}
tags: ${{ steps.meta.outputs.tag-names }}
labels: ${{ steps.meta.outputs.labels }}
archs: amd64
containerfiles: |
./Dockerfile.cli.slim
context: docker-context

- name: Push image to registry
id: push
if: github.event_name != 'pull_request'
uses: redhat-actions/push-to-registry@v2
with:
image: ${{ steps.build-image.outputs.image }}
tags: ${{ steps.build-image.outputs.tags }}
registry: ${{ env.REGISTRY }}

image-arm64:
needs: build
runs-on: ubuntu-24.04-arm
name: Build linux/arm64 image
permissions:
contents: read
packages: write
outputs:
digest: ${{ steps.push.outputs.digest }}
steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Download docker build context
uses: actions/download-artifact@v4
with:
name: synkronus-cli-docker-dist
path: docker-dist/

- name: Stage build context for Dockerfile.cli.slim
run: |
set -euo pipefail
mkdir -p docker-context
cp docker-dist/synk-linux-arm64 docker-context/synk
chmod +x docker-context/synk

- name: Log in to Github Container Registry
if: github.event_name != 'pull_request'
uses: redhat-actions/podman-login@v1
with:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
registry: ${{ env.REGISTRY }}

- name: Compute image metadata (scratch tag)
id: meta
uses: docker/metadata-action@v6
with:
images: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=tmp-${{ github.run_id }}-arm64

- name: Build image with Buildah
id: build-image
uses: redhat-actions/buildah-build@v2
with:
image: ${{ env.IMAGE_NAME }}
tags: ${{ steps.meta.outputs.tag-names }}
labels: ${{ steps.meta.outputs.labels }}
archs: arm64
containerfiles: |
./Dockerfile.cli.slim
context: docker-context

- name: Push image to registry
id: push
if: github.event_name != 'pull_request'
uses: redhat-actions/push-to-registry@v2
with:
image: ${{ steps.build-image.outputs.image }}
tags: ${{ steps.build-image.outputs.tags }}
registry: ${{ env.REGISTRY }}

merge-manifests:
if: github.event_name != 'pull_request'
needs: [image-amd64, image-arm64]
runs-on: ubuntu-latest
name: Merge multi-arch manifest and publish tags
permissions:
contents: read
packages: write
id-token: write
attestations: write
steps:
- name: Log in to Github Container Registry (docker)
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Compute image tags and labels
id: tags
uses: docker/metadata-action@v6
with:
images: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
flavor: |
latest=false
tags: |
type=semver,pattern=v{{version}},value=${{ github.event.release.tag_name }},enable=${{ github.event_name == 'release' }},priority=1100
type=semver,pattern=v{{major}}.{{minor}},value=${{ github.event.release.tag_name }},enable=${{ github.event_name == 'release' && github.event.release.prerelease == false }},priority=900
type=semver,pattern=v{{major}},value=${{ github.event.release.tag_name }},enable=${{ github.event_name == 'release' && github.event.release.prerelease == false }},priority=800
type=raw,value=latest,enable=${{ github.event_name == 'release' && github.event.release.prerelease == false }},priority=700
type=raw,value=latest-pre-release,enable=${{ github.event_name == 'release' && github.event.release.prerelease == true }},priority=700
type=raw,value=main,enable=${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }},priority=1000
type=raw,value=dev,enable=${{ github.event_name == 'push' && github.ref == 'refs/heads/dev' }},priority=1000
type=ref,event=branch,enable=${{ github.event_name == 'push' && github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }},priority=900
type=ref,event=pr,priority=1000
type=sha,enable=${{ github.event_name != 'release' && github.event_name != 'pull_request' }},priority=100

- name: Create and push multi-arch manifest
id: merge
env:
AMD64_DIGEST: ${{ needs.image-amd64.outputs.digest }}
ARM64_DIGEST: ${{ needs.image-arm64.outputs.digest }}
TAG_NAMES: ${{ steps.tags.outputs.tag-names }}
run: |
set -euo pipefail
IMG="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
AMD64_REF="${IMG}@${AMD64_DIGEST}"
ARM64_REF="${IMG}@${ARM64_DIGEST}"
mapfile -t TAGS < <(echo "${TAG_NAMES}" | sed '/^$/d')
if [ "${#TAGS[@]}" -eq 0 ]; then
echo "No tags from metadata-action" >&2
exit 1
fi
TAG_ARGS=()
for t in "${TAGS[@]}"; do
TAG_ARGS+=(-t "${IMG}:${t}")
done
docker buildx imagetools create "${TAG_ARGS[@]}" "${AMD64_REF}" "${ARM64_REF}"

- name: Verify image
shell: bash
run: |
set -euo pipefail
IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
PRIMARY_TAG="${{ steps.tags.outputs.version }}"

docker manifest inspect "${IMAGE}:${PRIMARY_TAG}" | \
jq -e '[.manifests[].platform | "\(.os)/\(.architecture)"] | index("linux/amd64") and index("linux/arm64")' >/dev/null
echo "Verified manifest platforms for ${IMAGE}:${PRIMARY_TAG}"

docker pull --platform linux/amd64 "${IMAGE}:${PRIMARY_TAG}"
docker pull --platform linux/arm64 "${IMAGE}:${PRIMARY_TAG}"

- name: Capture manifest digest for attestation
id: manifest-digest
run: |
set -euo pipefail
IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
PRIMARY_TAG="${{ steps.tags.outputs.version }}"
DIGEST=$(docker buildx imagetools inspect "${IMAGE}:${PRIMARY_TAG}" --format '{{json .}}' | jq -r '.manifest.digest')
echo "digest=${DIGEST}" >> "${GITHUB_OUTPUT}"

- name: Generate artifact attestation
uses: actions/attest-build-provenance@v1
with:
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
subject-digest: ${{ steps.manifest-digest.outputs.digest }}
push-to-registry: true
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ For stable, you usually **do not** re-bump client manifests if they already matc

**Synkronus server** version is **not** edited in source for releases — CI injects it from the git tag ([`.github/workflows/synkronus-docker.yml`](.github/workflows/synkronus-docker.yml)).

**Synkronus CLI** Docker image (`ghcr.io/opendataensemble/synkronus-cli`) is published by [`.github/workflows/synkronus-cli-docker.yml`](.github/workflows/synkronus-cli-docker.yml) on release; confirm GHCR tags alongside GitHub Release CLI binaries.

### Increment rules

- **Semver:** bump `MAJOR.MINOR.PATCH` in client manifests to match the release line (e.g. `1.1.1`).
Expand Down
13 changes: 13 additions & 0 deletions Dockerfile.cli.slim
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# CI/runtime-only image: expects a pre-built synk binary in the build context.
# Build context layout: ./synk (static binary)

FROM alpine:3.23

RUN apk --no-cache add ca-certificates

COPY synk /usr/local/bin/synk
RUN chmod +x /usr/local/bin/synk

WORKDIR /workspace

ENTRYPOINT ["/usr/local/bin/synk"]
4 changes: 2 additions & 2 deletions RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ Avoid tags like `v.1.2.0` (extra dot after `v`), which are not semver-style and
The ODE monorepo contains multiple artefacts that must stay in sync:

- **Synkronus** backend Docker image
- **Synkronus CLI** (multi-platform binaries)
- **Synkronus CLI** (multi-platform binaries and GHCR image `ghcr.io/opendataensemble/synkronus-cli`)
- **Formulus** React Native Android app (signed APK)

All share the same **semantic version tag**:
Expand All @@ -77,7 +77,7 @@ All share the same **semantic version tag**:

These tags are used consistently by the GitHub Actions workflows to:

- Build and tag Docker images in GHCR
- Build and tag Docker images in GHCR (Synkronus server and CLI)
- Attach CLI binaries to GitHub Releases
- Attach signed Android APKs to GitHub Releases

Expand Down
Loading
Loading