aboutsummaryrefslogtreecommitdiff
path: root/ci
diff options
context:
space:
mode:
Diffstat (limited to 'ci')
-rwxr-xr-xci/build.sh12
-rwxr-xr-xci/release.sh36
-rwxr-xr-xci/setup-toolchain.sh73
-rwxr-xr-xci/test.sh29
-rw-r--r--ci/testsshd/go.mod7
-rw-r--r--ci/testsshd/go.sum6
-rw-r--r--ci/testsshd/main.go149
-rwxr-xr-xci/verify-repro.sh23
8 files changed, 335 insertions, 0 deletions
diff --git a/ci/build.sh b/ci/build.sh
new file mode 100755
index 0000000..5afbfd4
--- /dev/null
+++ b/ci/build.sh
@@ -0,0 +1,12 @@
+#!/bin/sh
+# Full build: native executables (rsync + rsh via the NDK), then the APK.
+# Requires the pinned toolchain (see versions): Android SDK + NDK, a JDK, Go.
+set -eu
+
+root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
+
+make -C "$root" native
+make -C "$root" app
+
+echo "ci: APK at app/build/outputs/apk/release/"
+ls -l "$root"/app/build/outputs/apk/release/*.apk 2>/dev/null || true
diff --git a/ci/release.sh b/ci/release.sh
new file mode 100755
index 0000000..cec2b42
--- /dev/null
+++ b/ci/release.sh
@@ -0,0 +1,36 @@
+#!/bin/sh
+# Publish a signed release APK to a Codeberg (Forgejo) release. Run on a tag,
+# after ci/build.sh has produced a signed app-release.apk (signing happens when
+# keystore.properties is present).
+#
+# Environment:
+# CODEBERG_TOKEN Forgejo API token (secret)
+# CODEBERG_REPO owner/name, e.g. lena/rsend
+# TAG release tag (defaults to CI_COMMIT_TAG)
+# CODEBERG_URL base URL (defaults to https://codeberg.org)
+set -eu
+
+: "${CODEBERG_TOKEN:?set CODEBERG_TOKEN}"
+: "${CODEBERG_REPO:?set CODEBERG_REPO as owner/name}"
+tag=${TAG:-${CI_COMMIT_TAG:?set TAG or CI_COMMIT_TAG}}
+base=${CODEBERG_URL:-https://codeberg.org}
+apk=app/build/outputs/apk/release/app-release.apk
+
+[ -f "$apk" ] || { echo "release: $apk not found; run ci/build.sh first" >&2; exit 1; }
+
+api="$base/api/v1/repos/$CODEBERG_REPO"
+
+# Create the release for the tag and capture its id.
+id=$(curl -fsS -X POST "$api/releases" \
+ -H "Authorization: token $CODEBERG_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d "{\"tag_name\":\"$tag\",\"name\":\"$tag\"}" \
+ | sed -n 's/.*"id":\([0-9][0-9]*\).*/\1/p' | head -1)
+[ -n "$id" ] || { echo "release: could not create release for $tag" >&2; exit 1; }
+
+# Upload the APK as a release asset.
+curl -fsS -X POST "$api/releases/$id/assets?name=rsend-$tag.apk" \
+ -H "Authorization: token $CODEBERG_TOKEN" \
+ -F "attachment=@$apk" >/dev/null
+
+echo "release: uploaded rsend-$tag.apk to $CODEBERG_REPO ($tag)"
diff --git a/ci/setup-toolchain.sh b/ci/setup-toolchain.sh
new file mode 100755
index 0000000..c832708
--- /dev/null
+++ b/ci/setup-toolchain.sh
@@ -0,0 +1,73 @@
+#!/bin/sh
+# Provision the pinned Android build toolchain into TOOLCHAIN_DIR (default
+# $HOME/toolchains): JDK 17, Android cmdline-tools, SDK platform, build-tools,
+# NDK, and Gradle. Idempotent: re-running only fills what is missing. Writes an
+# env.sh to source before building. This is what CI runs to get a build host.
+set -eu
+
+root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
+. "$root/versions"
+
+dir=${TOOLCHAIN_DIR:-$HOME/toolchains}
+sdk="$dir/android-sdk"
+mkdir -p "$dir/dl"
+
+# JDK 17 (Temurin).
+if [ ! -x "$dir/jdk17/bin/java" ]; then
+ echo "setup: JDK $JDK_VERSION"
+ curl -fsSL "https://api.adoptium.net/v3/binary/latest/${JDK_VERSION}/ga/linux/x64/jdk/hotspot/normal/eclipse" -o "$dir/dl/jdk.tar.gz"
+ mkdir -p "$dir/jdk17"
+ tar xzf "$dir/dl/jdk.tar.gz" -C "$dir/jdk17" --strip-components=1
+fi
+export JAVA_HOME="$dir/jdk17"
+export PATH="$JAVA_HOME/bin:$PATH"
+
+# Android cmdline-tools.
+if [ ! -x "$sdk/cmdline-tools/latest/bin/sdkmanager" ]; then
+ echo "setup: cmdline-tools $ANDROID_CMDLINE_TOOLS"
+ curl -fsSL "https://dl.google.com/android/repository/commandlinetools-linux-${ANDROID_CMDLINE_TOOLS}_latest.zip" -o "$dir/dl/clt.zip"
+ rm -rf "$dir/dl/clt"
+ python3 -m zipfile -e "$dir/dl/clt.zip" "$dir/dl/clt"
+ mkdir -p "$sdk/cmdline-tools"
+ rm -rf "$sdk/cmdline-tools/latest"
+ mv "$dir/dl/clt/cmdline-tools" "$sdk/cmdline-tools/latest"
+ chmod +x "$sdk/cmdline-tools/latest/bin/"*
+fi
+
+# SDK packages.
+echo "setup: sdk packages"
+yes | "$sdk/cmdline-tools/latest/bin/sdkmanager" --licenses >/dev/null 2>&1 || true
+"$sdk/cmdline-tools/latest/bin/sdkmanager" \
+ "platform-tools" \
+ "platforms;android-${ANDROID_PLATFORM}" \
+ "build-tools;${ANDROID_BUILD_TOOLS}" \
+ "ndk;${ANDROID_NDK}" >/dev/null
+
+# Gradle.
+if [ ! -x "$dir/gradle-${GRADLE_VERSION}/bin/gradle" ]; then
+ echo "setup: Gradle $GRADLE_VERSION"
+ curl -fsSL "https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip" -o "$dir/dl/gradle.zip"
+ python3 -m zipfile -e "$dir/dl/gradle.zip" "$dir"
+ chmod +x "$dir/gradle-${GRADLE_VERSION}/bin/gradle"
+fi
+
+# Go (pinned; builds rsh).
+if [ ! -x "$dir/go/bin/go" ]; then
+ echo "setup: Go $GO_VERSION"
+ curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" -o "$dir/dl/go.tar.gz"
+ rm -rf "$dir/go"
+ tar xzf "$dir/dl/go.tar.gz" -C "$dir"
+fi
+
+# Emit the env to source before building.
+cat > "$dir/env.sh" <<EOF
+export JAVA_HOME="$dir/jdk17"
+export GOROOT="$dir/go"
+export ANDROID_HOME="$sdk"
+export ANDROID_SDK_ROOT="$sdk"
+export ANDROID_NDK_HOME="$sdk/ndk/${ANDROID_NDK}"
+export GRADLE_HOME="$dir/gradle-${GRADLE_VERSION}"
+export PATH="\$JAVA_HOME/bin:$dir/go/bin:\$GRADLE_HOME/bin:$sdk/cmdline-tools/latest/bin:$sdk/platform-tools:\$PATH"
+EOF
+
+echo "setup: done; source $dir/env.sh"
diff --git a/ci/test.sh b/ci/test.sh
new file mode 100755
index 0000000..8e8a3a4
--- /dev/null
+++ b/ci/test.sh
@@ -0,0 +1,29 @@
+#!/bin/sh
+# Host-verifiable checks: pinned source checksum, Go formatting, vet, and tests
+# (including the real-rsync-through-rsh end-to-end). Needs Go and rsync; no
+# Android toolchain. This is what runs on every push.
+set -eu
+
+root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
+. "$root/versions"
+
+if [ -f "$root/rsync/rsync-${RSYNC_VERSION}.tar.gz" ]; then
+ echo "ci: verifying pinned rsync checksum"
+ ( cd "$root/rsync" && sha256sum -c "rsync-${RSYNC_VERSION}.tar.gz.sha256" )
+fi
+
+echo "ci: gofmt"
+unformatted=$(gofmt -l "$root/rsh")
+if [ -n "$unformatted" ]; then
+ echo "ci: gofmt needed for:" >&2
+ echo "$unformatted" >&2
+ exit 1
+fi
+
+echo "ci: go vet"
+( cd "$root/rsh" && go vet ./... )
+
+echo "ci: go test"
+( cd "$root/rsh" && go test ./... )
+
+echo "ci: ok"
diff --git a/ci/testsshd/go.mod b/ci/testsshd/go.mod
new file mode 100644
index 0000000..88ccdc7
--- /dev/null
+++ b/ci/testsshd/go.mod
@@ -0,0 +1,7 @@
+module rsend/ci/testsshd
+
+go 1.25.0
+
+require golang.org/x/crypto v0.53.0
+
+require golang.org/x/sys v0.46.0 // indirect
diff --git a/ci/testsshd/go.sum b/ci/testsshd/go.sum
new file mode 100644
index 0000000..68cadc1
--- /dev/null
+++ b/ci/testsshd/go.sum
@@ -0,0 +1,6 @@
+golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
+golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
+golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
+golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
+golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
diff --git a/ci/testsshd/main.go b/ci/testsshd/main.go
new file mode 100644
index 0000000..19a53ac
--- /dev/null
+++ b/ci/testsshd/main.go
@@ -0,0 +1,149 @@
+// testsshd is a minimal SSH server for rsend integration tests: one host key,
+// an authorized_keys allowlist, and command execution via /bin/sh (so it runs
+// the remote rsync). It is not a product component; it stands in for a real
+// sshd so the full push path can be exercised on a device or in CI.
+package main
+
+import (
+ "flag"
+ "io"
+ "log"
+ "net"
+ "os"
+ "os/exec"
+
+ "golang.org/x/crypto/ssh"
+)
+
+func main() {
+ addr := flag.String("addr", "127.0.0.1:2222", "listen address")
+ hostKeyPath := flag.String("hostkey", "", "OpenSSH private host key (required)")
+ authPath := flag.String("authorized", "", "authorized_keys file (required)")
+ flag.Parse()
+
+ if *hostKeyPath == "" || *authPath == "" {
+ log.Fatal("testsshd: -hostkey and -authorized are required")
+ }
+
+ hostKey := mustSigner(*hostKeyPath)
+ allowed := mustAuthorized(*authPath)
+
+ cfg := &ssh.ServerConfig{
+ PublicKeyCallback: func(_ ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
+ if allowed[string(key.Marshal())] {
+ return &ssh.Permissions{}, nil
+ }
+ return nil, io.EOF
+ },
+ }
+ cfg.AddHostKey(hostKey)
+
+ ln, err := net.Listen("tcp", *addr)
+ if err != nil {
+ log.Fatalf("testsshd: listen: %v", err)
+ }
+ log.Printf("testsshd: listening on %s", ln.Addr())
+ for {
+ c, err := ln.Accept()
+ if err != nil {
+ log.Fatalf("testsshd: accept: %v", err)
+ }
+ go handle(c, cfg)
+ }
+}
+
+func mustSigner(path string) ssh.Signer {
+ b, err := os.ReadFile(path)
+ if err != nil {
+ log.Fatalf("testsshd: read host key: %v", err)
+ }
+ s, err := ssh.ParsePrivateKey(b)
+ if err != nil {
+ log.Fatalf("testsshd: parse host key: %v", err)
+ }
+ return s
+}
+
+func mustAuthorized(path string) map[string]bool {
+ b, err := os.ReadFile(path)
+ if err != nil {
+ log.Fatalf("testsshd: read authorized_keys: %v", err)
+ }
+ m := map[string]bool{}
+ for len(b) > 0 {
+ key, _, _, rest, err := ssh.ParseAuthorizedKey(b)
+ if err != nil {
+ break
+ }
+ m[string(key.Marshal())] = true
+ b = rest
+ }
+ if len(m) == 0 {
+ log.Fatal("testsshd: no authorized keys")
+ }
+ return m
+}
+
+func handle(nConn net.Conn, cfg *ssh.ServerConfig) {
+ conn, chans, reqs, err := ssh.NewServerConn(nConn, cfg)
+ if err != nil {
+ return
+ }
+ defer conn.Close()
+ go ssh.DiscardRequests(reqs)
+ for nc := range chans {
+ if nc.ChannelType() != "session" {
+ nc.Reject(ssh.UnknownChannelType, "only session")
+ continue
+ }
+ ch, requests, err := nc.Accept()
+ if err != nil {
+ return
+ }
+ go session(ch, requests)
+ }
+}
+
+func session(ch ssh.Channel, requests <-chan *ssh.Request) {
+ for req := range requests {
+ if req.Type != "exec" {
+ if req.WantReply {
+ req.Reply(false, nil)
+ }
+ continue
+ }
+ var payload struct{ Command string }
+ ssh.Unmarshal(req.Payload, &payload)
+ if req.WantReply {
+ req.Reply(true, nil)
+ }
+ code := run(payload.Command, ch)
+ ch.SendRequest("exit-status", false, ssh.Marshal(struct{ Status uint32 }{uint32(code)}))
+ ch.Close()
+ return
+ }
+}
+
+func run(cmd string, ch ssh.Channel) int {
+ c := exec.Command("/bin/sh", "-c", cmd)
+ c.Stdout = ch
+ c.Stderr = ch.Stderr()
+ in, err := c.StdinPipe()
+ if err != nil {
+ return 1
+ }
+ if err := c.Start(); err != nil {
+ return 1
+ }
+ go func() {
+ io.Copy(in, ch)
+ in.Close()
+ }()
+ if err := c.Wait(); err != nil {
+ if ee, ok := err.(*exec.ExitError); ok {
+ return ee.ExitCode()
+ }
+ return 1
+ }
+ return 0
+}
diff --git a/ci/verify-repro.sh b/ci/verify-repro.sh
new file mode 100755
index 0000000..d412713
--- /dev/null
+++ b/ci/verify-repro.sh
@@ -0,0 +1,23 @@
+#!/bin/sh
+# Build the native libs twice and diff them, catching nondeterminism early.
+# Requires the Android NDK and Go.
+set -eu
+
+root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
+a=$(mktemp -d)
+b=$(mktemp -d)
+trap 'rm -rf "$a" "$b"' EXIT
+
+make -C "$root" native
+cp -r "$root/app/src/main/jniLibs" "$a/jniLibs"
+
+rm -rf "$root/app/src/main/jniLibs" "$root/rsync/work" "$root/out"
+make -C "$root" native
+cp -r "$root/app/src/main/jniLibs" "$b/jniLibs"
+
+if diff -r "$a/jniLibs" "$b/jniLibs"; then
+ echo "verify-repro: native libs are bit-identical"
+else
+ echo "verify-repro: NONDETERMINISM detected" >&2
+ exit 1
+fi