Skip to content

0012-External CI/CD Distribution Plug-ins: Technical Design Document

TL;DR

Appcircle ships eight external CI/CD distribution plug-ins across four ecosystems: Fastlane, GitHub Actions, Jenkins, Azure DevOps. Each ecosystem has two product lines: Testing Distribution (TD) and Enterprise App Store (EAS). All of them do the same two jobs against the same public API. They exchange a Personal API Token (PAT) for a Bearer token, upload a build artifact, poll the task, and (for EAS) publish. They differ only in the host platform's packaging, manifest, and marketplace conventions. This document is the single reference for the full lifecycle: development, local testing, beta/pre-release, and production validation. It documents each plug-in with the same structure. The release mechanism differs per ecosystem and is not yet standardized. For the verified per-ecosystem reality and the target standard, see Plugin Release Policy.

A third product line, Publish (app store publishing), is being added across the same four ecosystems as separate plug-ins. Unlike TD/EAS, it can upload a binary and/or trigger the profile's publish flow; it is documented in its own Publish Module section below.

Status: πŸ“ Draft β€’ Linear: BE-8868 (analysis) β€’ Team: Backend β€’ Project: Marketplace Plugins

Introduction

Purpose: The development, local testing, beta/pre-release, and production validation of Appcircle's external CI/CD distribution plug-ins is currently scattered across idoc (releases/plugins) notes and the public marketplace docs, and is not described in sufficient detail anywhere. This TDD consolidates that knowledge into one authoritative reference for contributors and maintainers.

Scope: - External CI/CD distribution plug-ins only: Fastlane, GitHub Actions, Jenkins, Azure DevOps. Each ships in two product lines, Testing Distribution (TD) and Enterprise App Store (EAS). - Per plug-in: (1) development (repo structure, code layout, required files, conventions), (2) local testing, (3) beta/pre-release (release branch flow, versioning, tagging), (4) production validation (post-release verification). - No new authorization scopes. Documents the existing Personal API Token auth model and the self-hosted endpoint configuration (authEndpoint / apiEndpoint). - No UI surface, activity-log, webhook, or notification changes. This is a documentation deliverable.


System Overview

High-Level Description: Every plug-in implements the same two-phase contract against the Appcircle public API:

  1. Authenticate: exchange the user-supplied PAT for a short-lived Bearer access token at POST {authEndpoint}/auth/v{1|2}/token (body application/x-www-form-urlencoded, pat=<PAT>).
  2. Distribute: upload the artifact, poll GET /task/v1/tasks/{taskId} until completion, and for EAS optionally publish.

The plug-ins are thin clients. All business logic lives server-side in Appcircle. The differences between the eight repos are limited to host-platform packaging (gem, Node action, Jenkins HPI, Azure DevOps VSIX), manifest format, and the target marketplace.

Inventory & current versions

Ecosystem Product Repository Default branch Marketplace Version
Fastlane TD appcircleio/fastlane_plugin_appcircle_testing_distribution main RubyGems 0.2.2
Fastlane EAS appcircleio/fastlane-plugin-appcircle_enterprise_app_store main RubyGems 0.0.1
GitHub Actions TD appcircleio/appcircle-testing-distribution-githubaction main GitHub Marketplace 0.0.2
GitHub Actions EAS appcircleio/appcircle-enterprise-app-store-githubaction main GitHub Marketplace 0.0.2
Jenkins TD jenkinsci/appcircle-testing-distribution-plugin master Jenkins Update Center 1.11-SNAPSHOT
Jenkins EAS jenkinsci/appcircle-enterprise-app-store-plugin master Jenkins Update Center 1.9-SNAPSHOT
Azure DevOps TD appcircleio/appcircle-azuredevops-testdist-task-extension main Visual Studio Marketplace task 0.1.0 / pkg 0.0.70
Azure DevOps EAS appcircleio/appcircle-azuredevops-entstore-task-extension main Visual Studio Marketplace task 0.1.0 / pkg 0.0.70

Jenkins plug-ins live under the jenkinsci org (not appcircleio). This is a prerequisite for publishing to the Jenkins Update Center.


Architecture

System Architecture (data flow):

   CI/CD step (Fastlane / GH Action / Jenkins / Azure DevOps task)
         β”‚  PAT
         β–Ό
   POST {authEndpoint}/auth/v{1|2}/token        ──►  auth.appcircle.io   β†’ access_token
         β”‚  Authorization: Bearer <access_token>
         β–Ό
   ── Testing Distribution ───────────────────────────────────────────────
   GET/POST /distribution/v2/profiles            (resolve / create profile)
   POST     /distribution/v2/profiles/{id}/app-versions   (multipart File + Message) β†’ taskId
   ── Enterprise App Store ───────────────────────────────────────────────
   POST     /store/v2/profiles/app-versions      (multipart File)                    β†’ taskId
   PATCH    /store/v2/profiles/{id}/app-versions/{vid}?action=publish  (summary, releaseNotes, publishType)
   ───────────────────────────────────────────────────────────────────────
         β”‚
         β–Ό
   GET /task/v1/tasks/{taskId}                    ──►  poll until completed / failed

Technology Stack:

Ecosystem Language / runtime Packaging Build / bundle
Fastlane Ruby >= 2.6 RubyGem bundler / rake; dep rest-client
GitHub Actions TypeScript, Node 20 Action (dist/index.js) @vercel/ncc; deps @actions/core, axios, form-data
Jenkins Java, plugin-parent 4.85, jenkins.version 2.440.3 HPI Maven; deps json-api, apache-httpcomponents-client-4-api
Azure DevOps TypeScript, Node 16 handler VSIX tsc + tfx-cli; deps azure-pipelines-task-lib, axios

External Dependencies:

  • Appcircle Auth (auth.appcircle.io/auth/vN/token), Distribution API (/distribution/v2), Store API (/store/v2), Task API (/task/v1).
  • Marketplaces: RubyGems, GitHub Marketplace, Jenkins Update Center, Visual Studio Marketplace.
  • Build agents: macOS 14 (arm64), Ubuntu 22.04, RHEL 9 where applicable. Jenkins supports Linux (build matrix: Linux JDK 21 + Windows JDK 17).

Auth version drift: Fastlane and Jenkins call /auth/v2/token; GitHub Actions and Azure DevOps call /auth/v1/token. Both work today; new development should standardize on v2.


Key Components

Authentication (Personal API Token): all plug-ins

  • Purpose: Authenticate to Appcircle without storing long-lived credentials.
  • Responsibilities: Take the user-supplied PAT, exchange it for a Bearer access token, attach Authorization: Bearer <token> to all subsequent calls.
  • Interactions: POST {authEndpoint}/auth/v{1|2}/token.
  • Security: No new scopes. Jenkins stores the PAT encrypted as hudson.util.Secret; GitHub Actions / Azure DevOps rely on platform secret masking.

Self-hosted endpoint configuration: Azure DevOps only (today)

  • Purpose: Point the plug-in at a self-hosted Appcircle instance.
  • Responsibilities: Optional authEndpoint (default https://auth.appcircle.io) and apiEndpoint (default https://api.appcircle.io) inputs override the cloud hosts.
  • Status: Implemented only in the Azure DevOps extensions. Fastlane, GitHub Actions and Jenkins hardcode the cloud hosts; adding these two parameters is the documented path to bring them to parity.

Parameter matrix

Testing Distribution

Parameter Required Note
personalAPIToken βœ… PAT
profileName βœ… distribution profile
createProfileIfNotExists βž– default false
appPath βœ… artifact path (.ipa, .apk, .aab, .zip/.xcarchive)
message varies required in Fastlane/GH; optional in Azure DevOps
authEndpoint / apiEndpoint βž– Azure DevOps only

Enterprise App Store

Parameter Required Note
personalAPIToken βœ… PAT
appPath βœ… artifact path (.ipa, .apk; Azure DevOps EAS also .aab)
summary βœ… store summary
releaseNotes βœ… store release notes
publishType varies 0=None / 1=Beta / 2=Live; optional (default None) in GH
authEndpoint / apiEndpoint βž– Azure DevOps only

Detailed Design: Per-Plug-in Lifecycle

Each plug-in is documented with the same four headings: Development, Local Testing, Beta / Pre-release, Production Validation.

Fastlane

Development

lib/fastlane/plugin/<plugin>/
  version.rb                       # VERSION constant (single source of truth)
  <plugin>.rb                      # module + all_classes loader
  actions/<plugin>_action.rb       # Fastlane::Actions::<Name>Action
  helper/                          # auth + upload services
<plugin>.gemspec                   # spec.version = ...::VERSION
Gemfile / Rakefile / .rubocop.yml
fastlane/Fastfile, fastlane/Pluginfile
Jenkinsfile / pipeline.sh / ci/release.rb / Dockerfile.ci   # release automation (see Beta / Pre-release)
  • Action class extends Fastlane::Action; parameters declared as FastlaneCore::ConfigItem with env_name bindings (AC_PERSONAL_API_TOKEN, AC_PROFILE_NAME, AC_APP_PATH, AC_MESSAGE, …).
  • The only runtime dependency is rest-client. rubygems_mfa_required = 'false'. TD platforms [:ios, :android]; EAS all.
  • Self-hosted ready: both gems accept optional authEndpoint / apiEndpoint (env AC_AUTH_ENDPOINT / AC_API_ENDPOINT). When unset they default to the Appcircle cloud, so existing cloud users are unaffected. If the self-hosted server uses a self-signed or private-CA certificate, trust that CA on the machine running Fastlane (for example, SSL_CERT_FILE pointing at a PEM bundle). The gem does not disable TLS verification.

Local Testing

bundle install
bundle exec rake          # spec + rubocop (default task)
bundle exec rake spec     # rspec only
bundle exec rake rubocop  # lint only

Wire the local checkout into a real Fastlane project to run end-to-end:

# test app fastlane/Pluginfile
gem 'fastlane-plugin-appcircle_testing_distribution', path: '/path/to/local/checkout'
# Fastfile
lane :smoke do
  appcircle_testing_distribution(
    personalAPIToken: ENV['AC_PERSONAL_API_TOKEN'], profileName: 'QA',
    createProfileIfNotExists: true, appPath: 'build/app.ipa', message: 'local smoke test'
  )
end

EAS has a SimpleCov-enabled placeholder spec/; TD has no spec suite yet (gap).

Beta / Pre-release

Release runs on a Jenkins Multibranch Pipeline (Jenkinsfile β†’ pipeline.sh β†’ ci/release.rb, all executed inside the Dockerfile.ci Ruby image; RubyGems credential exposed as GEM_HOST_API_KEY via the Jenkins credential Appcircle-Fastlane-RubyGems-Cred). It is branch-gated, so a build never double-publishes:

  • Version source of truth = the latest git tag (vX.Y.Z). Tagging is manual: the developer pushes git tag vX.Y.Z; the pipeline never creates tags. version.rb is rewritten by the release script at publish time.
  • Push to release/x.y.z β†’ pipeline.sh prerelease β†’ publishes a RubyGems pre-release x.y.z.beta.N. N auto-increments from the betas already on RubyGems for that base version.
  • Merge to main β†’ pipeline.sh production β†’ publishes the production x.y.z; idempotent (skips if that version is already on RubyGems).

RubyGems beta semantics: a pre-release version is dotted (x.y.z.beta.N, not npm-style -beta.1) and is hidden from gem install / fastlane add_plugin unless the user opts in. There is no dist-tag channel.

See Plugin Release Policy β†’ Fastlane.

Production Validation

Test the published gem (not just local code), and verify the beta does not shadow production:

  • Production: gem install fastlane-plugin-appcircle_<...> / fastlane add_plugin appcircle_<...> must resolve the latest stable version.
  • Beta: only an explicit opt-in pulls it: gem install <gem> --pre (or an exact --version x.y.z.beta.N). Confirm a normal install still resolves the prod version.

Then run a lane against a real Appcircle profile and confirm: upload + task completion, every previously-documented parameter still accepted (backward compatible), the new authEndpoint/apiEndpoint work against a self-hosted instance and default to cloud when unset, and the README documents the new behavior.

GitHub Actions

Development

action.yml            # name, branding, inputs, runs.using: node20, main: dist/index.js
src/index.ts          # entry β†’ run()
src/main.ts           # input parsing + orchestration
src/api/authApi.ts    # PAT β†’ token
src/api/uploadApi.ts  # upload + poll (+ publish for EAS)
dist/index.js         # ncc-bundled output, COMMITTED to the repo
package.json / tsconfig.json / script/release
  • Runtime node20; bundled with @vercel/ncc. action.yml inputs mirror the parameter matrix; TD branding blue, EAS orange.
  • GitHub runs the committed dist/. Regenerate and commit it whenever src/ changes.

Local Testing

npm install
npm run package        # ncc build src/index.ts -o dist  (regenerate bundle)
npm test               # jest (no test files yet: gap)
npm run lint
- TD has a CI workflow (test.yml) that runs the action via uses: ./; EAS has no CI workflow (gap). - For full local execution use act or a throwaway branch workflow.

Beta / Pre-release

⚠️ No release branches and no publish automation today. script/release only creates a vX.Y.Z tag and pushes it. There is no GitHub Release / Marketplace publish step, no moving major tag (v1), and no check-dist guard. dist/ is usually rebuilt on a feature branch, so the tagged commit's dist/ and package.json can drift. See Plugin Release Policy β†’ GitHub Actions for the verified state and target standard.

Production Validation

Reference the action by published tag in a test workflow; confirm upload + task completion, old inputs still resolve, and new inputs appear in the Marketplace listing.

Jenkins

Development

pom.xml                                   # artifactId, ${revision}${changelist}, parent 4.85, jenkins.version 2.440.3
src/main/java/io/jenkins/plugins/appcircle/.../<Name>Builder.java
src/main/java/.../AuthService.java, UploadService.java, Models/
src/main/resources/.../<Builder>/config.jelly   # UI form
Jenkinsfile                               # buildPlugin matrix (Linux JDK21 / Windows JDK17)
.github/workflows/                        # TD: cd.yaml + jenkins-security-scan.yml; EAS: release-drafter.yml + jenkins-security-scan.yml (no cd.yaml)
  • Main class extends Builder implements SimpleBuildStep; @Symbol("appcircleTestingDistribution" | "appcircleEnterpriseAppStore") enables pipeline DSL.
  • Parameters bound via @DataBoundConstructor / @DataBoundSetter; PAT stored encrypted as hudson.util.Secret. Cloud-only today.
  • artifactIds: appcircle-testing-distribution, appcircle-enterprise-store.

Local Testing

mvn hpi:run     # dev Jenkins with the plugin loaded
mvn test        # JenkinsRule tests (only configRoundtrip stubs: gap)
mvn package     # build target/<artifactId>.hpi
Deploy the .hpi via Manage Jenkins β†’ Plugins β†’ Advanced β†’ Deploy Plugin, then validate with a freestyle job and a pipeline step:
appcircleTestingDistribution(
  personalAPIToken: env.AC_PAT, profileName: 'QA',
  createProfileIfNotExists: true, appPath: 'app.apk', message: 'pipeline test'
)

Beta / Pre-release

Releases use the official Continuous Delivery of Plugins (JEP-229) on master (not maven-release-plugin, no release branches). The version is pom.xml's ${revision}${changelist}; set <revision> to the target X.Y.Z, keeping the git tag equal to <revision>. A maintainer triggers the release through cd.yaml, which delegates to the jenkins-infra reusable maven-cd workflow (workflow_dispatch from the Actions tab, or a pushed X.Y.Z tag) and deploys to repo.jenkins-ci.org; the Update Center picks it up within a few hours. TD already ships cd.yaml; EAS has no cd.yaml yet (only release-drafter), so onboarding it to CD is the prerequisite for an automated deploy. See Plugin Release Policy β†’ Jenkins.

Production Validation

Install from the Update Center on a clean Jenkins; run freestyle + pipeline jobs (including a Linux agent); confirm config-roundtrip, old job configs still load, and new features present.

Azure DevOps

Development

vss-extension.json        # publisher Appcircle, version, files, categories
<taskfolder>/task.json    # id (UUID), inputs, execution Node16 -> index.js
<taskfolder>/index.ts     # entry point (TD: buildandreleasetask/, EAS: task/)
configs/dev.json          # id ...-dev, public:false
configs/release.json      # id ..., public:true
package.json              # tsc build, tfx-cli; CI via pipeline.sh + Jenkinsfile
  • TD task AppcircleTestingDistribution (folder buildandreleasetask/, target es6); EAS task AppcircleEnterpriseStore (folder task/, target es2022).
  • Inputs include the standard matrix plus authEndpoint / apiEndpoint, which is the self-hosted hook. Compiled with tsc (no bundler).

Local Testing

yarn install
(cd <taskfolder> && yarn install)
yarn package          # tsc ./<taskfolder>/index.ts -> index.js
tfx extension create --manifest-globs vss-extension.json --overrides-file configs/dev.json
Install the generated .vsix into a test org and run it in a pipeline. No unit-test framework is wired up (gap), so validation is integration-based.

Beta / Pre-release

βœ… This is the one ecosystem that follows a release-branch β†’ main flow end-to-end. pipeline.sh publishes the private -dev extension from release/* (beta) and the public extension from main. The version is bumped on the release branch (task.json + vss-extension.json), merged to main, tagged, then merged back to develop. ⚠️ Branch naming is inconsistent (release/0.1.0 semver vs release/6031 numeric id). See Plugin Release Policy β†’ Azure DevOps.

Production Validation

Add the task to a test pipeline; exercise both cloud and a self-hosted endpoint via authEndpoint/apiEndpoint; confirm upload + task completion + (EAS) publish, old inputs still resolve, and publishType options render.


Publish Module

A third product line, Publish (app store publishing), is being added across all four ecosystems as separate plug-ins. Unlike TD/EAS (which only upload/distribute), the Publish plug-in can upload a binary to a Publish profile and/or trigger the profile's publish flow (submitting an app version to the App Store / Google Play). Behavior is identical across the four ecosystems; only host-platform packaging differs (gem, Node action, Azure VSIX, Jenkins HPI).

Repositories & status

Ecosystem Repo Status
Fastlane fastlane-plugin-appcircle_publish (RubyGems) Beta 0.1.0.beta.1 published; dev-verified
GitHub Actions appcircle-publish-githubaction dev-verified; beta cut
Azure DevOps appcircle-azuredevops-publish-task-extension dev-verified (mock-run)
Jenkins appcircle-publish-plugin β†’ jenkinsci/… built + dev-verified; jenkinsci hosting requested

Parameter matrix

Parameter Required Note
personalAPIToken βœ… PAT (Fastlane also accepts personalAccessKey)
platform βœ… ios / android (Publish profiles are platform-scoped)
publishProfile βœ… Publish profile name (unique per platform)
upload βž– default false; upload the binary as a new app version
publish βž– default false; trigger the profile's publish flow
appPath βœ… if upload artifact path (.ipa, .apk, .aab)
authEndpoint / apiEndpoint βž– self-hosted endpoints (cloud defaults)

At least one of upload / publish must be enabled.

Behavior modes

upload publish Behavior
βœ… βž– Upload appPath as a new app version on the profile.
βž– βœ… Trigger the publish flow for the profile's current release candidate.
βœ… βœ… Upload, mark the new version as release candidate, then publish it.
βž– βž– Error: nothing to do.

Rules:

  • In-progress guard: a new publish is never started if one is already running for the target profile.
  • Release candidate: in the upload+publish mode the uploaded version is auto-marked as release candidate; publish-only publishes the profile's existing release candidate.
  • Progress: the publish status is polled and each flow step is logged once with a human-readable status label + icon.

API contract (verified on dev)

Purpose Method Path
Resolve profile GET /publish/v2/profiles/{platform} (match by name)
Upload information GET /publish/v1/profiles/{platform}/{id}/app-versions?action=uploadInformation&fileName=&fileSize=
Upload binary PUT / POST signed uploadUrl (PUT octet-stream, or POST multipart with file field last)
Commit upload POST /publish/v1/profiles/{platform}/{id}/app-versions?action=commitFileUpload β†’ {taskId}
Upload task poll GET /task/v1/tasks/{taskId} (stateValue 3=Completed, 2=Canceled)
Mark release candidate PATCH /publish/v2/profiles/{platform}/{id}/app-versions/{vid}?action=releaseCandidate body {"ReleaseCandidate":true}
In-progress check GET /build/v1/queue/my-dashboard?page=1&size=1000 (filter publishId != null && profileId == target)
Publish steps β†’ run id GET /publish/v2/profiles/{platform}/{id}/app-versions/{vid}/publish β†’ steps[0].publishId
Start publish POST /publish/v2/profiles/{platform}/{id}/publish/{publishId}?action=restart body "{}"
Publish status poll GET /publish/v1/profiles/{platform}/{id}/app-versions/{vid}/publish β†’ status (0=success, 1=failed, else running)

v1 vs v2: the signed-URL upload/commit actions live on publish/v1 (the publish/v2 app-versions path ignores action=uploadInformation and returns the version list instead). Profile listing and the publish trigger are on publish/v2. Auth is the same PAT model as TD/EAS (/auth/v1/token on GitHub/Jenkins/Azure, /auth/v2/token for PAT on Fastlane).

FlowStepStatus (step-log labels): 0 Success, 1 Failed, 2 Cancelled, 3 Timeout, 90 Waiting, 91 Running, 92 Completing, 99 Unknown, 100 Skipped, 200 Not Started, 201 Stopped, 202 In Progress, 203 Awaiting Response.

CI note: if the profile's publish flow contains a manual-approval step (e.g. "Get Approval via Email"), the flow blocks on it and the plug-in keeps polling until it completes or the poll times out. Prefer flows without manual gates for CI.


Integration and Interfaces

External Interfaces:

Purpose Default host Path family
Auth https://auth.appcircle.io /auth/vN/token
API (TD) https://api.appcircle.io /distribution/v2/...
API (EAS) https://api.appcircle.io /store/v2/...
Task polling https://api.appcircle.io /task/v1/tasks/{taskId}
  • TD flow: resolve profile by profileName (create if createProfileIfNotExists), upload (POST /distribution/v2/profiles/{id}/app-versions), poll task.
  • EAS flow: upload (POST /store/v2/profiles/app-versions), poll task, and if publishType != None select the most recent profile (by lastBinaryReceivedDate), then PATCH .../app-versions/{vid}?action=publish.

Internal Interfaces: None added. The plug-ins are external API clients only.


Deployment and Infrastructure

Cloud Deployment

No Appcircle Helm/cluster changes. The plug-ins are published to external marketplaces (RubyGems, GitHub Marketplace, Jenkins Update Center, Visual Studio Marketplace). See Plugin Release Policy for the per-marketplace release flow.

Self-Hosted Deployment

No Docker Compose / install-script changes. Self-hosted customers configure the Azure DevOps extension's authEndpoint / apiEndpoint inputs to target their instance; bringing self-hosted support to the other three ecosystems requires adding the same two inputs.


Testing and Quality Assurance

Test Strategy:

Integration Local-test recipe
Fastlane bundle exec rake; path:-link the gem into a test app's Pluginfile and run a lane.
GitHub Actions npm run package; run via uses: ./ on a branch or with act.
Jenkins mvn hpi:run, or mvn package + deploy .hpi; run freestyle + pipeline.
Azure DevOps yarn package + tfx extension create --overrides-file configs/dev.json; install private .vsix and run in a pipeline.

Release-flow verification: confirm the documented release produces the correct published version on each marketplace (RubyGems install, GH @vX.Y.Z + major tag, Jenkins Update Center pickup, Visual Studio Marketplace public extension validated first via the private -dev).

Post-release checklist (reproducible):

  1. No regression: a known-good build still uploads and the task completes.
  2. Backward-compatible params: every previously-documented input still accepted; Jenkins saved job configs still load.
  3. New features present: new inputs/behaviors appear and function.
  4. Smoke test: end-to-end against a real Appcircle profile (TD) / store profile + publish (EAS), including a self-hosted endpoint run for Azure DevOps.

Quality Assurance: RuboCop (Fastlane), ESLint/Prettier (GitHub Actions, Azure DevOps), Jenkins security scan + Release Drafter (Jenkins). Test coverage is a known gap across all repos.


Performance Considerations

The plug-ins are short-lived CI steps; throughput is not a concern. Two practical limits apply: artifact uploads can be large (up to ~2 GB) and use multipart streaming, and task polling caps at ~100 attempts. No caching or scaling strategy applies.


Maintenance and Support

  • Monitoring: plug-in execution surfaces in the host CI logs; server-side behavior is observed in the Appcircle backend. No plug-in-side telemetry.
  • Support and Maintenance: keep marketplace listings current, regenerate the GitHub Actions dist/ bundle on every source change, and keep Jenkins baseline (jenkins.version) and plugin-parent up to date.

Risks and Mitigation Strategies

Risk Impact Mitigation
Stale GitHub Actions dist/ bundle Published action runs old code Enforce npm run package + commit on the tagged commit; add a check-dist CI guard that the bundle matches src/
Thin test coverage (all repos) Regressions ship undetected Add real unit tests; rely on the reproducible post-release checklist meanwhile
Inconsistent release automation (Fastlane/GH manual & unautomated; Jenkins EAS has no CD; only Azure DevOps fully follows a release-branch flow) Human error, orphaned release branches, tag/bundle drift Converge toward the per-marketplace standard in the Plugin Release Policy; note release/x.y.z is not idiomatic for Jenkins (use JEP-229 CD, not release branches)
Auth version drift (v1 vs v2) Divergent behavior, future breakage Standardize on /auth/v2/token
Self-hosted parity (only Azure DevOps) Self-hosted users blocked on other ecosystems Add authEndpoint/apiEndpoint inputs to Fastlane, GitHub Actions, Jenkins

Appendix