diff --git a/.env.local.example b/.env.local.example new file mode 100644 index 0000000..6380f95 --- /dev/null +++ b/.env.local.example @@ -0,0 +1,29 @@ +# uatu release credentials (local dev only). +# +# Copy to `.env.local` (gitignored) and fill in values ONLY if you need to +# fire a real release from your laptop. Day-to-day work and the `release-cli` +# / `release-android-local` / `release-npm-dry` Make targets don't need any +# of these — they're snapshot/local-only. +# +# In CI, these are provided via GitHub Actions secrets (see .github/workflows/release.yml). + +# npm automation token (bypasses 2FA). +# Create at npmjs.com → Access Tokens → Generate New Token → Automation. +NPM_TOKEN= + +# Sonatype Central user token (username half). +# Create at central.sonatype.com → Account → Generate User Token. +ORG_GRADLE_PROJECT_mavenCentralUsername= + +# Sonatype Central user token (password half). +ORG_GRADLE_PROJECT_mavenCentralPassword= + +# ASCII-armored GPG private key for release signing. Include the full +# "-----BEGIN PGP PRIVATE KEY BLOCK-----…-----END PGP PRIVATE KEY BLOCK-----" +# payload, with literal \n newlines escaped inside quotes, e.g.: +# ORG_GRADLE_PROJECT_signingInMemoryKey="-----BEGIN PGP PRIVATE KEY BLOCK-----\nlQVYBG…\n-----END PGP PRIVATE KEY BLOCK-----" +# Generate with: gpg --export-secret-keys --armor +ORG_GRADLE_PROJECT_signingInMemoryKey= + +# Passphrase for the GPG key above. +ORG_GRADLE_PROJECT_signingInMemoryKeyPassword= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..057378d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,72 @@ +name: ci + +on: + # Runs on PRs (opened / synchronize / reopened — the defaults) and manual + # dispatch only. We deliberately don't run on direct pushes to master: + # master is PR-merge-only, and PR validation already covers the merge + # commit via the `synchronize` event on the PR branch. + pull_request: + workflow_dispatch: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + + - name: Set up Android SDK + uses: android-actions/setup-android@v3 + + - name: Set up Node 22 + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: pkg/spec-api/package-lock.json + + - name: Install buf + uses: bufbuild/buf-setup-action@v1 + + - name: Install protoc plugins + run: | + go install google.golang.org/protobuf/cmd/protoc-gen-go@latest + go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest + echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" + + - name: Cache Gradle + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + gradle-${{ runner.os }}- + + - name: Bootstrap + run: make bootstrap + + - name: Lint proto + run: buf lint + + - name: Go vet + run: go vet ./... + + - name: Run tests + run: make test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ff197d6 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,148 @@ +name: release + +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + tag: + description: "Tag to release (e.g. v0.0.1-rc1). Must already exist." + required: true + type: string + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + release-android: + name: Publish sdk-android to Maven Central + runs-on: ubuntu-latest + env: + ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_USERNAME }} + ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_CENTRAL_PASSWORD }} + ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.SIGNING_IN_MEMORY_KEY }} + ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.SIGNING_IN_MEMORY_KEY_PASSWORD }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag || github.ref }} + + - name: Resolve version + id: ver + run: | + raw="${{ inputs.tag || github.ref_name }}" + echo "version=${raw#v}" >> "$GITHUB_OUTPUT" + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + + - name: Set up Android SDK + uses: android-actions/setup-android@v3 + + - name: Cache Gradle + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + gradle-${{ runner.os }}- + + - name: Publish to Maven Central + run: ./gradlew :sdk-android:publishAndReleaseToMavenCentral -Puatu.version=${{ steps.ver.outputs.version }} + + release-npm: + name: Publish @uatu/spec to npm + runs-on: ubuntu-latest + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag || github.ref }} + + - name: Resolve version + id: ver + run: | + raw="${{ inputs.tag || github.ref_name }}" + echo "version=${raw#v}" >> "$GITHUB_OUTPUT" + + - name: Set up Node 22 + uses: actions/setup-node@v4 + with: + node-version: "22" + registry-url: "https://registry.npmjs.org" + cache: npm + cache-dependency-path: pkg/spec-api/package-lock.json + + - name: Install dependencies + working-directory: pkg/spec-api + run: npm ci + + - name: Stamp version + working-directory: pkg/spec-api + run: npm version ${{ steps.ver.outputs.version }} --no-git-tag-version --allow-same-version + + - name: Publish + working-directory: pkg/spec-api + # npm tag pre-releases (e.g. 0.1.0-rc1) as "next" so npm install @uatu/spec + # keeps resolving the latest stable. + run: | + if [[ "${{ steps.ver.outputs.version }}" == *-* ]]; then + npm publish --access public --tag next + else + npm publish --access public + fi + + release-cli: + name: Publish uatu CLI to GitHub Releases + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag || github.ref }} + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + + - name: Set up Android SDK + uses: android-actions/setup-android@v3 + + - name: Cache Gradle + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + gradle-${{ runner.os }}- + + - name: Build sidecar JAR + run: make sidecar + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + version: "~> v2" + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 90439c2..511bea6 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,9 @@ node_modules/ # Sidecar fat JAR — build artifact copied in by `make uatu` before # `go build -tags withsidecar`. Never commit: it's ~130 MB. internal/sidecar/assets/sidecar-all.jar + +# spec-api compiled output +pkg/spec-api/dist/ + +# goreleaser local output +dist/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..dac6cdc --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,64 @@ +version: 2 + +project_name: uatu + +before: + hooks: + # The Go binary embeds the sidecar fat JAR via //go:embed gated by the + # `withsidecar` build tag. Rebuild the JAR and stage it at the embed + # path so the `go build` below picks up fresh bytes. + - make sidecar + - sh -c 'mkdir -p internal/sidecar/assets && cp sidecar/build/libs/sidecar-all.jar internal/sidecar/assets/sidecar-all.jar' + +builds: + - id: uatu + main: ./cmd/uatu + binary: uatu + env: + - CGO_ENABLED=0 + flags: + - -tags=withsidecar + ldflags: + - -s -w -X main.Version={{.Version}} + goos: + - darwin + - linux + goarch: + - amd64 + - arm64 + +archives: + - id: uatu + ids: + - uatu + formats: [tar.gz] + name_template: "uatu_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + files: + - README.md + - LICENSE* + +checksum: + name_template: checksums.txt + algorithm: sha256 + +snapshot: + version_template: "{{ incpatch .Version }}-snapshot-{{ .ShortCommit }}" + +changelog: + use: github + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" + - "^chore:" + - "^ci:" + - Merge pull request + - Merge branch + +release: + github: + owner: priyanshujain + name: uatu + draft: false + prerelease: auto diff --git a/Makefile b/Makefile index a67c9eb..6afbc01 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ SIDECAR_EMBED := internal/sidecar/assets/sidecar-all.jar SDK_AAR := sdk/android/build/outputs/aar/sdk-android-release.aar UATU_BIN := bin/uatu -.PHONY: bootstrap proto sidecar sdk-android sdk-android-publish uatu test test-go test-kotlin test-spec-api clean +.PHONY: bootstrap proto sidecar sdk-android sdk-android-publish uatu test test-go test-kotlin test-spec-api clean release-cli release-android-local release-npm-dry bootstrap: $(GO) mod download @@ -29,8 +29,7 @@ sdk-android: ANDROID_HOME=$(ANDROID_HOME) $(GRADLE) :sdk-android:assembleRelease sdk-android-publish: - @if [ -z "$$GH_TOKEN" ]; then echo "GH_TOKEN must be set" >&2; exit 1; fi - ANDROID_HOME=$(ANDROID_HOME) $(GRADLE) :sdk-android:publish + ANDROID_HOME=$(ANDROID_HOME) $(GRADLE) :sdk-android:publishToMavenLocal uatu: $(UATU_BIN) @@ -55,5 +54,18 @@ test-spec-api: clean: $(GO) clean - rm -rf bin + rm -rf bin dist pkg/spec-api/dist $(GRADLE) clean + +# Local release dry-runs. None of these touch remote registries. + +release-cli: + $(MAKE) sidecar + goreleaser release --snapshot --clean + +release-android-local: + @if [ -f .env.local ]; then set -a; . ./.env.local; set +a; fi; \ + ANDROID_HOME=$(ANDROID_HOME) $(GRADLE) :sdk-android:publishToMavenLocal -Puatu.version=0.0.0-local + +release-npm-dry: + cd pkg/spec-api && npm ci && npm run build && npm pack --dry-run diff --git a/README.md b/README.md index d2f3a5c..85ff953 100644 --- a/README.md +++ b/README.md @@ -6,3 +6,46 @@ Testing framework and spec in ts/js used for blackbox testing and property based ## Supported Platforms - android - ios + +## Install + +### CLI + +Download the platform tarball from [GitHub Releases](https://github.com/priyanshujain/uatu/releases/latest): + +```sh +# macOS arm64 +curl -L https://github.com/priyanshujain/uatu/releases/latest/download/uatu__darwin_arm64.tar.gz | tar xz +# Linux amd64 +curl -L https://github.com/priyanshujain/uatu/releases/latest/download/uatu__linux_amd64.tar.gz | tar xz + +./uatu version +``` + +Pre-built for `darwin/arm64`, `darwin/amd64`, `linux/amd64`, `linux/arm64`. + +### Spec API (npm) + +```sh +npm install --save-dev @uatu/spec +``` + +```ts +import { extract, always, actions } from "@uatu/spec"; +``` + +### Android SDK (Maven Central) + +```kotlin +// settings.gradle.kts +dependencyResolutionManagement { + repositories { + mavenCentral() + } +} + +// app/build.gradle.kts +dependencies { + implementation("io.github.priyanshujain:sdk-android:") +} +``` diff --git a/cmd/uatu/main.go b/cmd/uatu/main.go index 7282d60..bcb0949 100644 --- a/cmd/uatu/main.go +++ b/cmd/uatu/main.go @@ -10,6 +10,10 @@ import ( "time" ) +// Version is stamped at build time via goreleaser ldflags. +// Default "dev" marks untagged local builds. +var Version = "dev" + type testOptions struct { spec string bundleID string @@ -29,6 +33,7 @@ Usage: Commands: test Run a spec against an app for a fixed duration. doctor Check that the host environment is ready to run uatu. + version Print the uatu version. Run "uatu -h" for command-specific flags. ` @@ -87,6 +92,9 @@ func run(args []string, stdout, stderr io.Writer) error { return runTest(options, stdout) case "doctor": return runDoctor(stdout) + case "version", "-v", "--version": + fmt.Fprintln(stdout, Version) + return nil default: return fmt.Errorf("unknown command: %q (try 'uatu help')", args[1]) } diff --git a/cmd/uatu/main_test.go b/cmd/uatu/main_test.go index bfeb1d8..ee401ab 100644 --- a/cmd/uatu/main_test.go +++ b/cmd/uatu/main_test.go @@ -95,6 +95,22 @@ func TestRun_HelpPrintsUsage(t *testing.T) { } } +func TestRun_VersionPrintsVersion(t *testing.T) { + prev := Version + Version = "1.2.3-test" + defer func() { Version = prev }() + + for _, arg := range []string{"version", "--version", "-v"} { + var stdout bytes.Buffer + if err := run([]string{"uatu", arg}, &stdout, io.Discard); err != nil { + t.Fatalf("%s: %v", arg, err) + } + if strings.TrimSpace(stdout.String()) != "1.2.3-test" { + t.Errorf("%s: got %q, want 1.2.3-test", arg, stdout.String()) + } + } +} + func TestRun_UnknownCommand(t *testing.T) { err := run([]string{"uatu", "wat"}, io.Discard, io.Discard) if err == nil || !strings.Contains(err.Error(), "unknown command") { diff --git a/gradle.properties b/gradle.properties index 07df4f5..d355a7a 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,3 +3,8 @@ org.gradle.parallel=true org.gradle.caching=true kotlin.code.style=official android.useAndroidX=true + +# Single source of truth at release time is the git tag (e.g. v0.1.0 → 0.1.0), +# forwarded via `-Puatu.version=...` or `ORG_GRADLE_PROJECT_uatu.version`. +# The default below only applies to untagged local builds. +uatu.version=0.0.0-dev diff --git a/pkg/spec-api/README.md b/pkg/spec-api/README.md new file mode 100644 index 0000000..d2c0230 --- /dev/null +++ b/pkg/spec-api/README.md @@ -0,0 +1,39 @@ +# @uatu/spec + +TypeScript spec API for [uatu](https://github.com/priyanshujain/uatu) — a property-based UI fuzzer for mobile apps. + +Spec authors write specs in TypeScript that describe what an app should *always* do (safety invariants), generate weighted actions to exercise the app, and extract structured state from the accessibility tree. The `uatu` CLI picks up the spec and drives the app under test. + +## Install + +```sh +npm install --save-dev @uatu/spec +``` + +## Usage + +```ts +import { extract, always, actions, Tap, weighted } from "@uatu/spec"; + +export const spec = { + extract: extract((tree) => ({ + onHomeScreen: tree.some((n) => n.text === "Home"), + })), + + always: always(({ state }) => state.onHomeScreen || !state.startedOnHome), + + actions: actions(({ tree }) => + weighted([ + [1, Tap(tree.first((n) => n.text === "Checkout"))], + ]), + ), +}; +``` + +## Version compatibility + +`@uatu/spec` is released in lockstep with the uatu CLI. Pin the same major/minor version as your installed `uatu` binary. + +## License + +Apache-2.0 diff --git a/pkg/spec-api/package-lock.json b/pkg/spec-api/package-lock.json index 12be727..ecf6c7c 100644 --- a/pkg/spec-api/package-lock.json +++ b/pkg/spec-api/package-lock.json @@ -1,15 +1,16 @@ { "name": "@uatu/spec", - "version": "0.0.1", + "version": "0.0.0-dev", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@uatu/spec", - "version": "0.0.1", + "version": "0.0.0-dev", + "license": "Apache-2.0", "devDependencies": { "@types/node": "^22.7.5", - "typescript": "^5.6.3" + "typescript": "^5.9.3" } }, "node_modules/@types/node": { diff --git a/pkg/spec-api/package.json b/pkg/spec-api/package.json index cbeccb0..384ecb8 100644 --- a/pkg/spec-api/package.json +++ b/pkg/spec-api/package.json @@ -1,17 +1,42 @@ { "name": "@uatu/spec", - "version": "0.0.1", - "description": "TypeScript spec API for Uatu (vendored locally; not published to npm in v0.1).", - "private": true, + "version": "0.0.0-dev", + "description": "TypeScript spec API for uatu — a property-based UI fuzzer for mobile apps.", "type": "module", - "main": "src/index.ts", - "types": "src/index.ts", + "types": "./dist/index.d.ts", + "main": "./dist/index.js", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "README.md" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/priyanshujain/uatu.git", + "directory": "pkg/spec-api" + }, + "homepage": "https://github.com/priyanshujain/uatu", + "bugs": { + "url": "https://github.com/priyanshujain/uatu/issues" + }, + "license": "Apache-2.0", + "author": "Priyanshu Jain", "scripts": { + "build": "tsc -p tsconfig.build.json", + "prepublishOnly": "npm run build", "check": "tsc --noEmit", "test": "node --test --experimental-strip-types test/*.test.ts" }, + "publishConfig": { + "access": "public" + }, "devDependencies": { "@types/node": "^22.7.5", - "typescript": "^5.6.3" + "typescript": "^5.9.3" } } diff --git a/pkg/spec-api/tsconfig.build.json b/pkg/spec-api/tsconfig.build.json new file mode 100644 index 0000000..bd456a1 --- /dev/null +++ b/pkg/spec-api/tsconfig.build.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "emitDeclarationOnly": false, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": true + }, + "include": ["src/**/*.ts"], + "exclude": ["test/**/*", "dist/**/*", "node_modules"] +} diff --git a/sdk/android/build.gradle.kts b/sdk/android/build.gradle.kts index 2b9dac8..962f9f3 100644 --- a/sdk/android/build.gradle.kts +++ b/sdk/android/build.gradle.kts @@ -1,9 +1,15 @@ +import com.vanniktech.maven.publish.AndroidSingleVariantLibrary +import com.vanniktech.maven.publish.SonatypeHost + plugins { id("com.android.library") version "8.11.0" kotlin("android") version "2.1.21" - `maven-publish` + id("com.vanniktech.maven.publish") version "0.30.0" } +version = findProperty("uatu.version") as String? ?: "0.0.0-dev" +group = "io.github.priyanshujain" + android { namespace = "dev.uatu.sdk" compileSdk = 35 @@ -25,34 +31,61 @@ android { testOptions { unitTests.isReturnDefaultValues = true } - - publishing { - singleVariant("release") { - withSourcesJar() - } - } } -publishing { - publications { - register("release") { - groupId = "dev.uatu" - artifactId = "sdk-android" - version = "0.0.1" - afterEvaluate { - from(components["release"]) +mavenPublishing { + publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL, automaticRelease = true) + + // Sign only when a release-signing key is provided (env or Gradle + // property). Unsigned runs are useful for `publishToMavenLocal` dry-runs; + // CI always has the key set so the actual Central push is always signed. + if (findProperty("signingInMemoryKey") != null) { + signAllPublications() + } + + configure( + AndroidSingleVariantLibrary( + variant = "release", + sourcesJar = true, + publishJavadocJar = true, + ), + ) + + coordinates( + groupId = "io.github.priyanshujain", + artifactId = "sdk-android", + version = version.toString(), + ) + + pom { + name.set("uatu sdk-android") + description.set( + "Android runtime SDK for uatu — a property-based UI fuzzer for mobile apps. " + + "Exposes a content-provider accessibility bridge consumed by the uatu CLI at test time.", + ) + url.set("https://github.com/priyanshujain/uatu") + + licenses { + license { + name.set("Apache License, Version 2.0") + url.set("https://www.apache.org/licenses/LICENSE-2.0.txt") + distribution.set("repo") } } - } - repositories { - maven { - name = "GitHubPackages" - url = uri("https://maven.pkg.github.com/priyanshujain/uatu") - credentials { - username = System.getenv("GH_USERNAME") ?: System.getenv("GITHUB_ACTOR") ?: "priyanshujain" - password = System.getenv("GH_TOKEN") ?: System.getenv("GITHUB_TOKEN") ?: "" + + developers { + developer { + id.set("priyanshujain") + name.set("Priyanshu Jain") + url.set("https://github.com/priyanshujain") } } + + scm { + url.set("https://github.com/priyanshujain/uatu") + connection.set("scm:git:git://github.com/priyanshujain/uatu.git") + developerConnection.set("scm:git:ssh://git@github.com/priyanshujain/uatu.git") + } } } diff --git a/sdk/android/sample/build.gradle.kts b/sdk/android/sample/build.gradle.kts index f505cd9..280fafa 100644 --- a/sdk/android/sample/build.gradle.kts +++ b/sdk/android/sample/build.gradle.kts @@ -12,7 +12,7 @@ android { minSdk = 24 targetSdk = 35 versionCode = 1 - versionName = "0.0.1" + versionName = findProperty("uatu.version") as String? ?: "0.0.0-dev" } compileOptions { diff --git a/sidecar/build.gradle.kts b/sidecar/build.gradle.kts index c342446..b1a4e4b 100644 --- a/sidecar/build.gradle.kts +++ b/sidecar/build.gradle.kts @@ -7,6 +7,8 @@ plugins { id("com.google.protobuf") version "0.9.5" } +version = findProperty("uatu.version") as String? ?: "0.0.0-dev" + java { toolchain { languageVersion.set(JavaLanguageVersion.of(17))