diff --git a/.cursor/skills/ode-synk-bundle-publish/SKILL.md b/.cursor/skills/ode-synk-bundle-publish/SKILL.md index 2b4e1ddf9..6d6f1c1b9 100644 --- a/.cursor/skills/ode-synk-bundle-publish/SKILL.md +++ b/.cursor/skills/ode-synk-bundle-publish/SKILL.md @@ -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) diff --git a/.github/CICD.md b/.github/CICD.md index dc45ad62f..fa0b39673 100644 --- a/.github/CICD.md +++ b/.github/CICD.md @@ -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` diff --git a/.github/workflows/synkronus-cli-docker.yml b/.github/workflows/synkronus-cli-docker.yml new file mode 100644 index 000000000..bc783ec4e --- /dev/null +++ b/.github/workflows/synkronus-cli-docker.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index da86e598e..a0f08d97a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`). diff --git a/Dockerfile.cli.slim b/Dockerfile.cli.slim new file mode 100644 index 000000000..48a1c0fa1 --- /dev/null +++ b/Dockerfile.cli.slim @@ -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"] diff --git a/RELEASE.md b/RELEASE.md index fc7924a2a..562f993ca 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -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**: @@ -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 diff --git a/synkronus-cli/AGENTS.md b/synkronus-cli/AGENTS.md index b56309242..a449ed846 100644 --- a/synkronus-cli/AGENTS.md +++ b/synkronus-cli/AGENTS.md @@ -29,4 +29,6 @@ The CLI may be **GPL-2.0-or-later** until the QR dependency cleanup described in ## Install / build -From [README.md](README.md): `go install`, or build from source. Installation scripts are linked from the root [README.md](../README.md). +From [README.md](README.md): `go install`, release binaries from GitHub, or `ghcr.io/opendataensemble/synkronus-cli`. Installation scripts: [`scripts/install-synkronus-cli.sh`](../scripts/install-synkronus-cli.sh). + +CI login: `synk login -u USER --password "$SYNK_PASSWORD"` (or `SYNK_PASSWORD` env when stdin is not a TTY). diff --git a/synkronus-cli/README.md b/synkronus-cli/README.md index f86d682ba..3b33a62c4 100644 --- a/synkronus-cli/README.md +++ b/synkronus-cli/README.md @@ -120,9 +120,13 @@ Add-Content -Path $PROFILE -Value "synk completion powershell | Out-String | Inv ### Authentication ```bash -# Login to the API +# Login to the API (interactive password prompt) synk login --username your-username +# Login for CI/scripts (non-interactive) +synk login --username your-username --password "$SYNK_PASSWORD" +# or: SYNK_PASSWORD=secret synk login -u your-username + # Check authentication status synk status diff --git a/synkronus-cli/internal/cmd/auth.go b/synkronus-cli/internal/cmd/auth.go index 4e05e5824..20ca9a1ad 100644 --- a/synkronus-cli/internal/cmd/auth.go +++ b/synkronus-cli/internal/cmd/auth.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "os" "syscall" "time" @@ -17,7 +18,9 @@ func init() { loginCmd := &cobra.Command{ Use: "login", Short: "Login to the Synkronus API", - Long: `Authenticate with the Synkronus API using your username and password.`, + Long: `Authenticate with the Synkronus API using your username and password. + +For CI and scripting, pass --password or set SYNK_PASSWORD instead of typing interactively.`, RunE: func(cmd *cobra.Command, args []string) error { username, err := cmd.Flags().GetString("username") if err != nil { @@ -29,14 +32,10 @@ func init() { fmt.Scanln(&username) } - fmt.Print("Password: ") - passwordBytes, err := term.ReadPassword(int(syscall.Stdin)) + password, err := resolveLoginPassword(cmd) if err != nil { - return fmt.Errorf("error reading password: %w", err) + return err } - fmt.Println() // Add newline after password input - - password := string(passwordBytes) tokenResp, err := auth.Login(username, password) if err != nil { return fmt.Errorf("login failed: %w", err) @@ -54,6 +53,7 @@ func init() { } loginCmd.Flags().StringP("username", "u", "", "Username for authentication") + loginCmd.Flags().String("password", "", "Password for authentication (or set SYNK_PASSWORD)") rootCmd.AddCommand(loginCmd) // Logout command @@ -102,3 +102,24 @@ func init() { } rootCmd.AddCommand(statusCmd) } + +func resolveLoginPassword(cmd *cobra.Command) (string, error) { + password, err := cmd.Flags().GetString("password") + if err != nil { + return "", err + } + if password == "" { + password = os.Getenv("SYNK_PASSWORD") + } + if password != "" { + return password, nil + } + + fmt.Print("Password: ") + passwordBytes, err := term.ReadPassword(int(syscall.Stdin)) + if err != nil { + return "", fmt.Errorf("error reading password: %w", err) + } + fmt.Println() + return string(passwordBytes), nil +} diff --git a/synkronus-cli/internal/cmd/version.go b/synkronus-cli/internal/cmd/version.go index eb1bbff7f..6a83a0192 100644 --- a/synkronus-cli/internal/cmd/version.go +++ b/synkronus-cli/internal/cmd/version.go @@ -11,7 +11,7 @@ import ( var ( // Version is the CLI version, set during build - Version = "1.1.1" + Version = "1.1.2" // BuildDate is the date when the CLI was built BuildDate = "unknown" // CommitHash is the git commit hash diff --git a/synkronus-cli/versioninfo.json b/synkronus-cli/versioninfo.json index 0416da8ba..6e33b7aa3 100644 --- a/synkronus-cli/versioninfo.json +++ b/synkronus-cli/versioninfo.json @@ -3,13 +3,13 @@ "FileVersion": { "Major": 1, "Minor": 1, - "Patch": 1, + "Patch": 2, "Build": 0 }, "ProductVersion": { "Major": 1, "Minor": 1, - "Patch": 1, + "Patch": 2, "Build": 0 }, "FileFlagsMask": "3f", @@ -22,14 +22,14 @@ "Comments": "Synkronus CLI Tool", "CompanyName": "OpenDataEnsemble", "FileDescription": "Synkronus CLI - A command-line interface for the Synkronus API", - "FileVersion": "1.1.1.0", + "FileVersion": "1.1.2.0", "InternalName": "synk", "LegalCopyright": "© 2025 OpenDataEnsemble", "LegalTrademarks": "", "OriginalFilename": "synk.exe", "PrivateBuild": "", "ProductName": "Synkronus CLI", - "ProductVersion": "1.1.1.0", + "ProductVersion": "1.1.2.0", "SpecialBuild": "" }, "VarFileInfo": {