diff --git a/.github/workflows/publish-api-clients.yml b/.github/workflows/publish-api-clients.yml new file mode 100644 index 0000000..18f2cab --- /dev/null +++ b/.github/workflows/publish-api-clients.yml @@ -0,0 +1,118 @@ +name: Publish API Clients + +on: + workflow_call: + inputs: + spec-path: + required: true + type: string + api-name: + required: true + type: string + version: + required: true + type: string + ts-package: + required: true + type: string + maven-group: + required: true + type: string + java-artifact: + required: true + type: string + kotlin-artifact: + required: true + type: string + dry-run: + required: false + type: boolean + default: false + openapi-client-gradle-version: + required: false + type: string + default: "0.3.0" + node-version: + required: false + type: string + default: "24" + java-version: + required: false + type: string + default: "21" + npm-registry-url: + required: false + type: string + default: "https://npm.pkg.github.com" + secrets: + packages-token: + required: false + node-auth-token: + required: false + +permissions: + contents: read + packages: write + id-token: write + +jobs: + clients: + name: API Clients + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/checkout@v6 + with: + repository: JorisJonkers-dev/github-workflows + ref: ${{ github.job_workflow_sha }} + path: .github-workflows + persist-credentials: false + + - uses: ./.github-workflows/actions/setup-java-gradle + with: + java-version: ${{ inputs.java-version }} + github-packages-token: ${{ secrets.packages-token || github.token }} + + - uses: ./.github-workflows/actions/setup-node + with: + node-version: ${{ inputs.node-version }} + package-manager: npm + install-command: "npm install --no-audit --no-fund" + working-directory: ${{ runner.temp }}/api-clients/typescript + github-packages-token: ${{ secrets.node-auth-token || secrets.packages-token || github.token }} + + - name: Generate and verify all clients + uses: ./.github-workflows/actions/api-client-publish + with: + mode: dry-run + spec-path: ${{ inputs.spec-path }} + api-name: ${{ inputs.api-name }} + version: ${{ inputs.version }} + ts-package: ${{ inputs.ts-package }} + maven-group: ${{ inputs.maven-group }} + java-artifact: ${{ inputs.java-artifact }} + kotlin-artifact: ${{ inputs.kotlin-artifact }} + openapi-client-gradle-version: ${{ inputs.openapi-client-gradle-version }} + npm-registry-url: ${{ inputs.npm-registry-url }} + env: + GITHUB_TOKEN: ${{ secrets.packages-token || github.token }} + NODE_AUTH_TOKEN: ${{ secrets.node-auth-token || secrets.packages-token || github.token }} + + - name: Publish all clients + if: ${{ !inputs.dry-run }} + uses: ./.github-workflows/actions/api-client-publish + with: + mode: publish + spec-path: ${{ inputs.spec-path }} + api-name: ${{ inputs.api-name }} + version: ${{ inputs.version }} + ts-package: ${{ inputs.ts-package }} + maven-group: ${{ inputs.maven-group }} + java-artifact: ${{ inputs.java-artifact }} + kotlin-artifact: ${{ inputs.kotlin-artifact }} + openapi-client-gradle-version: ${{ inputs.openapi-client-gradle-version }} + npm-registry-url: ${{ inputs.npm-registry-url }} + env: + GITHUB_TOKEN: ${{ secrets.packages-token || github.token }} + NODE_AUTH_TOKEN: ${{ secrets.node-auth-token || secrets.packages-token || github.token }} diff --git a/actions/api-client-publish/action.yml b/actions/api-client-publish/action.yml new file mode 100644 index 0000000..d894c60 --- /dev/null +++ b/actions/api-client-publish/action.yml @@ -0,0 +1,52 @@ +name: Publish API Clients +description: Generate, verify, and publish Java, Kotlin, and TypeScript API clients from an OpenAPI spec. + +inputs: + mode: + description: Execution mode. Use dry-run or publish. + required: true + spec-path: + description: Path to the OpenAPI spec relative to GITHUB_WORKSPACE. + required: true + api-name: + description: API name used to derive generated package names. + required: true + version: + description: Client package version. A leading v is stripped. + required: true + ts-package: + description: npm package name for the generated TypeScript client. + required: true + maven-group: + description: Maven group ID for the generated JVM clients. + required: true + java-artifact: + description: Maven artifact ID for the generated Java client. + required: true + kotlin-artifact: + description: Maven artifact ID for the generated Kotlin client. + required: true + openapi-client-gradle-version: + description: Version of dev.jorisjonkers.openapi-client Gradle plugin to apply. + required: true + npm-registry-url: + description: npm registry URL used for publishConfig and authentication. + required: true + +runs: + using: composite + steps: + - name: Generate, build, and publish clients + shell: bash + env: + INPUT_MODE: ${{ inputs.mode }} + INPUT_SPEC_PATH: ${{ inputs.spec-path }} + INPUT_API_NAME: ${{ inputs.api-name }} + INPUT_VERSION: ${{ inputs.version }} + INPUT_TS_PACKAGE: ${{ inputs.ts-package }} + INPUT_MAVEN_GROUP: ${{ inputs.maven-group }} + INPUT_JAVA_ARTIFACT: ${{ inputs.java-artifact }} + INPUT_KOTLIN_ARTIFACT: ${{ inputs.kotlin-artifact }} + INPUT_OPENAPI_CLIENT_GRADLE_VERSION: ${{ inputs.openapi-client-gradle-version }} + INPUT_NPM_REGISTRY_URL: ${{ inputs.npm-registry-url }} + run: "$GITHUB_ACTION_PATH/run.sh" diff --git a/actions/api-client-publish/run.sh b/actions/api-client-publish/run.sh new file mode 100755 index 0000000..d2460a9 --- /dev/null +++ b/actions/api-client-publish/run.sh @@ -0,0 +1,471 @@ +#!/usr/bin/env bash +set -euo pipefail + +readonly HEY_API_OPENAPI_TS_VERSION="0.99.0" +readonly TYPESCRIPT_VERSION="6.0.3" +readonly ZOD_VERSION="4.4.3" + +die() { + echo "::error::$*" >&2 + exit 1 +} + +required_env() { + local name="$1" + if [[ -z "${!name:-}" ]]; then + die "$name is required" + fi +} + +reject_unsafe_string() { + local name="$1" + local value="$2" + + if [[ "$value" == *$'\n'* || "$value" == *$'\r'* ]]; then + die "$name must not contain newlines" + fi + if [[ "$value" == *\"* || "$value" == *\\* ]]; then + die "$name contains unsupported characters" + fi +} + +validate_identifier() { + local name="$1" + local value="$2" + local pattern="$3" + + if [[ ! "$value" =~ $pattern ]]; then + die "$name has an invalid value: $value" + fi +} + +derive_jvm_package_base() { + local maven_group="$1" + local api_name="$2" + local api_slug package_base segment + + api_slug="$(printf '%s' "$api_name" | tr '[:upper:]' '[:lower:]')" + api_slug="${api_slug%-api}" + api_slug="$(printf '%s' "$api_slug" | sed -E 's/[^a-z0-9]+/./g; s/^\.+//; s/\.+$//; s/\.+/./g')" + + if [[ -z "$api_slug" ]]; then + die "api-name must produce a non-empty JVM package segment" + fi + + package_base="${maven_group}.${api_slug}.client" + IFS='.' read -r -a segments <<< "$package_base" + for segment in "${segments[@]}"; do + if [[ ! "$segment" =~ ^[a-z_][a-z0-9_]*$ ]]; then + die "Derived JVM package segment is invalid: $segment" + fi + done + + printf '%s\n' "$package_base" +} + +configure_npm_auth() { + local registry="$1" + local package_name="$2" + local npmrc_path=".npmrc" + local registry_host scope + + if [[ -z "${NODE_AUTH_TOKEN:-}" ]]; then + return 0 + fi + + registry="${registry%/}" + registry_host="${registry#http://}" + registry_host="${registry_host#https://}" + scope="${package_name%%/*}" + + touch "$npmrc_path" + if [[ "$scope" == @* ]] && ! grep -qF "${scope}:registry=${registry}" "$npmrc_path"; then + printf '%s:registry=%s\n' "$scope" "$registry" >> "$npmrc_path" + fi + if ! grep -qF "//${registry_host}/:_authToken=" "$npmrc_path"; then + printf '//%s/:_authToken=%s\n' "$registry_host" "\${NODE_AUTH_TOKEN}" >> "$npmrc_path" + fi +} + +write_settings_gradle() { + local output="$1" + + cat > "$output" <<'GRADLE' +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + maven { + name = "JorisJonkersDevOpenApiClientGradle" + url = uri("https://maven.pkg.github.com/JorisJonkers-dev/openapi-client-gradle") + credentials { + username = + providers + .gradleProperty("gpr.user") + .orElse(providers.environmentVariable("GITHUB_ACTOR")) + .orNull + password = + providers + .gradleProperty("gpr.token") + .orElse(providers.environmentVariable("GITHUB_TOKEN")) + .orNull + } + } + } + resolutionStrategy { + eachPlugin { + if (requested.id.id == "dev.jorisjonkers.openapi-client") { + useModule("dev.jorisjonkers:openapi-client-gradle:${requested.version}") + } + } + } +} + +dependencyResolutionManagement { + repositories { + mavenCentral() + maven("https://repo.spring.io/milestone") + } +} + +rootProject.name = "api-clients" + +include(":java", ":kotlin") +GRADLE +} + +write_root_build_gradle() { + local output="$1" + local maven_group="$2" + local version="$3" + + cat > "$output" < "$output" <> "$output" <> "$output" <> "$output" <("mavenJava") { + from(components["java"]) + groupId = project.group.toString() + artifactId = "$artifact_id" + version = project.version.toString() + } + } + repositories { + maven { + name = "GitHubPackages" + val repository = + providers + .environmentVariable("GITHUB_REPOSITORY") + .orElse("JorisJonkers-dev/api-clients") + .get() + url = uri("https://maven.pkg.github.com/\$repository") + credentials { + username = + providers + .gradleProperty("gpr.user") + .orElse(providers.environmentVariable("GITHUB_ACTOR")) + .orNull + password = + providers + .gradleProperty("gpr.token") + .orElse(providers.environmentVariable("GITHUB_TOKEN")) + .orNull + } + } + } +} +GRADLE +} + +write_typescript_package() { + local package_dir="$1" + local package_name="$2" + local version="$3" + local registry="$4" + + ( + cd "$package_dir" + NODE_PACKAGE_NAME="$package_name" \ + NODE_PACKAGE_VERSION="$version" \ + NODE_REGISTRY_URL="${registry%/}" \ + NODE_HEY_API_OPENAPI_TS_VERSION="$HEY_API_OPENAPI_TS_VERSION" \ + NODE_TYPESCRIPT_VERSION="$TYPESCRIPT_VERSION" \ + NODE_ZOD_VERSION="$ZOD_VERSION" \ + node <<'NODE' +const fs = require('node:fs') + +const pkg = { + name: process.env.NODE_PACKAGE_NAME, + version: process.env.NODE_PACKAGE_VERSION, + type: 'module', + files: ['dist'], + exports: { + '.': { + types: './dist/index.d.ts', + import: './dist/index.js', + }, + }, + publishConfig: { + registry: process.env.NODE_REGISTRY_URL, + access: 'public', + }, + scripts: { + generate: 'openapi-ts -f openapi-ts.config.ts', + build: 'tsc -p tsconfig.json', + }, + dependencies: { + zod: process.env.NODE_ZOD_VERSION, + }, + devDependencies: { + '@hey-api/openapi-ts': process.env.NODE_HEY_API_OPENAPI_TS_VERSION, + typescript: process.env.NODE_TYPESCRIPT_VERSION, + }, +} + +fs.writeFileSync('package.json', `${JSON.stringify(pkg, null, 2)}\n`) +NODE + ) +} + +write_typescript_config() { + local output="$1" + local spec_path="$2" + + cat > "$output" < "$output" <<'JSON' +{ + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": false, + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmitOnError": true, + "outDir": "dist", + "rootDir": "src", + "strict": true, + "target": "ES2022", + "verbatimModuleSyntax": true + }, + "include": ["src/**/*.ts"] +} +JSON +} + +main() { + required_env INPUT_MODE + required_env INPUT_SPEC_PATH + required_env INPUT_API_NAME + required_env INPUT_VERSION + required_env INPUT_TS_PACKAGE + required_env INPUT_MAVEN_GROUP + required_env INPUT_JAVA_ARTIFACT + required_env INPUT_KOTLIN_ARTIFACT + required_env INPUT_OPENAPI_CLIENT_GRADLE_VERSION + required_env INPUT_NPM_REGISTRY_URL + required_env GITHUB_WORKSPACE + required_env RUNNER_TEMP + + local mode="$INPUT_MODE" + local spec_path="$INPUT_SPEC_PATH" + local api_name="$INPUT_API_NAME" + local version="${INPUT_VERSION#v}" + local ts_package="$INPUT_TS_PACKAGE" + local maven_group="$INPUT_MAVEN_GROUP" + local java_artifact="$INPUT_JAVA_ARTIFACT" + local kotlin_artifact="$INPUT_KOTLIN_ARTIFACT" + local plugin_version="$INPUT_OPENAPI_CLIENT_GRADLE_VERSION" + local npm_registry_url="${INPUT_NPM_REGISTRY_URL%/}" + local workspace_real source_spec source_spec_real clients_root jvm_dir ts_dir spec_dir + local spec_extension spec_file_name jvm_spec_path ts_spec_path package_base + + version="${version#V}" + + case "$mode" in + dry-run|publish) + ;; + *) + die "mode must be dry-run or publish" + ;; + esac + + [[ -n "$version" ]] || die "version is empty after normalization" + [[ "$spec_path" != /* ]] || die "spec-path must be relative to GITHUB_WORKSPACE" + + reject_unsafe_string "api-name" "$api_name" + reject_unsafe_string "version" "$version" + reject_unsafe_string "maven-group" "$maven_group" + reject_unsafe_string "java-artifact" "$java_artifact" + reject_unsafe_string "kotlin-artifact" "$kotlin_artifact" + reject_unsafe_string "openapi-client-gradle-version" "$plugin_version" + + validate_identifier "maven-group" "$maven_group" '^[A-Za-z0-9_.-]+$' + validate_identifier "java-artifact" "$java_artifact" '^[A-Za-z0-9_.-]+$' + validate_identifier "kotlin-artifact" "$kotlin_artifact" '^[A-Za-z0-9_.-]+$' + validate_identifier "openapi-client-gradle-version" "$plugin_version" '^[A-Za-z0-9_.+-]+$' + validate_identifier "ts-package" "$ts_package" '^(@[a-z0-9][a-z0-9._-]*/)?[a-z0-9][a-z0-9._-]*$' + + workspace_real="$(realpath "$GITHUB_WORKSPACE")" + source_spec_real="$(realpath -m "$workspace_real/$spec_path")" + case "$source_spec_real" in + "$workspace_real"/*) + ;; + *) + die "spec-path must stay within GITHUB_WORKSPACE" + ;; + esac + + if [[ ! -f "$source_spec_real" ]]; then + die "OpenAPI spec file does not exist: $source_spec_real" + fi + + package_base="$(derive_jvm_package_base "$maven_group" "$api_name")" + clients_root="$RUNNER_TEMP/api-clients" + jvm_dir="$clients_root/jvm" + ts_dir="$clients_root/typescript" + spec_dir="$clients_root/spec" + + spec_extension="${source_spec_real##*.}" + if [[ "$spec_extension" == "$source_spec_real" ]]; then + spec_extension="json" + else + spec_extension="$(printf '%s' "$spec_extension" | tr '[:upper:]' '[:lower:]')" + fi + spec_file_name="openapi.${spec_extension}" + source_spec="$spec_dir/$spec_file_name" + jvm_spec_path="../spec/$spec_file_name" + ts_spec_path="../spec/$spec_file_name" + + rm -rf "$jvm_dir" + mkdir -p "$jvm_dir/java" "$jvm_dir/kotlin" "$spec_dir" + if [[ -d "$ts_dir" ]]; then + find "$ts_dir" -mindepth 1 -maxdepth 1 ! -name .npmrc -exec rm -rf {} + + else + mkdir -p "$ts_dir" + fi + mkdir -p "$ts_dir/src" + cp "$source_spec_real" "$source_spec" + + write_settings_gradle "$jvm_dir/settings.gradle.kts" + write_root_build_gradle "$jvm_dir/build.gradle.kts" "$maven_group" "$version" + write_jvm_build_gradle "$jvm_dir/java/build.gradle.kts" "java" "$java_artifact" "$plugin_version" "$jvm_spec_path" "$package_base" + write_jvm_build_gradle "$jvm_dir/kotlin/build.gradle.kts" "kotlin" "$kotlin_artifact" "$plugin_version" "$jvm_spec_path" "$package_base" + + ( + cd "$ts_dir" + configure_npm_auth "$npm_registry_url" "$ts_package" + ) + write_typescript_package "$ts_dir" "$ts_package" "$version" "$npm_registry_url" + write_typescript_config "$ts_dir/openapi-ts.config.ts" "$ts_spec_path" + write_tsconfig "$ts_dir/tsconfig.json" + printf "export * from './generated/index'\n" > "$ts_dir/src/index.ts" + + command -v gradle >/dev/null 2>&1 || die "gradle is required on PATH" + command -v npm >/dev/null 2>&1 || die "npm is required on PATH" + command -v node >/dev/null 2>&1 || die "node is required on PATH" + + gradle --no-daemon -p "$jvm_dir" :java:build :kotlin:build + + ( + cd "$ts_dir" + npm install --no-audit --no-fund + npm run generate + npm run build + ) + + case "$mode" in + dry-run) + gradle --no-daemon -p "$jvm_dir" :java:publishToMavenLocal :kotlin:publishToMavenLocal + ( + cd "$ts_dir" + npm pack --dry-run + ) + ;; + publish) + if [[ -z "${GITHUB_TOKEN:-}" ]]; then + die "GITHUB_TOKEN is required for Maven publishing" + fi + if [[ -z "${NODE_AUTH_TOKEN:-}" ]]; then + die "NODE_AUTH_TOKEN is required for npm publishing" + fi + gradle --no-daemon -p "$jvm_dir" :java:publish :kotlin:publish + ( + cd "$ts_dir" + npm publish --access public --provenance + ) + ;; + esac +} + +main "$@"