Upload builds from CI/CD

Updated · 8 min read · For developers, release managers

This page is for developers who want every push to become a build. You create an upload key for an app, store it as a CI secret, and add one step to your workflow. Omnihorn stores the folder as a new build and can assign it to the channels that follow the pushed branch.

How a CI upload works

A CI job builds your app, then uploads the output folder with an app-specific API key. Omnihorn stores it as a new build, scans and verifies every file, and signs the manifest. Once the build is active, it is assigned to the channel you named in the workflow and to every channel that follows the pushed branch.

Each CI build remembers its branch or tag, commit, commit message, who pushed it and a link to the CI run. The Builds tab shows the branch and short commit next to every such build. Files that were already uploaded in an earlier build are not uploaded again.

API keys

Create keys on the app’s CI/CD tab with Create key. A key starts with nk_ and is shown once, so copy it straight into a CI secret.

  • A key belongs to one app. It can publish builds to that app, to any channel it names, and to every channel that follows the branch it reports.
  • A key cannot read other apps, other keys or billing.
  • An app can have 10 active keys. Revoking a key takes effect on the very next request.
  • One key allows 60 requests a minute. Use one key per repository.

Set up GitHub Actions

  1. Create a key

    In Omnihorn, open your app, then the CI/CD tab, and choose Create key. Copy the key.

  2. Store it as a repository secret

    In GitHub, open the repository, then Settings, Secrets and variables, Actions, New repository secret. Name it OMNIHORN_API_KEY and paste the key.

  3. Add the workflow

    Copy the workflow from the CI/CD tab into .github/workflows/release.yml, or use the one below.

  4. Point it at your build output

    Replace the build step with your own build command. Make sure the files end up in the folder you pass as path, here ./dist.

  5. Push and read the job summary

    Push to a branch the branches: filter matches. The job summary shows the version, the branch and what happened to each channel.

.github/workflows/release.yml
name: "Release my app"

on:
  push:
    branches: [main, 'release/**']

jobs:
  release:
    runs-on: windows-latest
    steps:
      - uses: actions/checkout@v4

      # Build your app here so that its files end up in ./dist
      - name: Build
        run: echo "Replace this step with your build command"

      - uses: Ivorn-Technologies/upload-build@v1
        with:
          api-key: ${{ secrets.OMNIHORN_API_KEY }}
          path: ./dist

Inputs and outputs

InputDefaultWhat it does
api-keynoneThe app’s key, read from a secret. Required.
pathnoneThe folder whose files make up the build. Required. Symbolic links and .git are skipped.
versionlatestBase version MAJOR.MINOR.PATCH. Omnihorn appends +build.N. Without it, the latest build’s base version is reused.
release-notesemptyThe "What’s new" text for the build, up to 10,000 characters. You can add or edit it later on the build’s page.
channelnoneAlso assign the build to this existing channel once it is active.
create-branch-channeltrueCreate a private channel for a branch that no channel follows yet.
waittrueWait until the build is active or failed, up to 15 minutes.
OutputWhat it holds
build-idThe build’s id.
versionThe full version, for example 1.4.0+build.42.
statusactive, failed, or processing when wait is false.
channelsJSON list of channels with channelId, name, action (assigned, created or unchanged) and an optional reason.

The step fails with exit code 1 when the upload fails, the build fails verification, or the build is deleted before the step finishes. The message names the file or the step.

Branch channels and following a branch

A channel can follow a branch. Open the channel on the Channels tab and set the Follow a branch card to an exact name such as main, or to a pattern where * matches anything, including /. The pattern release/* matches release/1.0 and release/1.0/hotfix. Separate several branches with commas, for example main,master. The first channel of a new app already follows main,master, so the first push from your default branch lands there without any setup. Each build from a matching branch is assigned to the channel once it is active.

A push to a branch that no channel follows creates a channel for it. The channel is named after the branch (feature/Login becomes feature-login), it is private, so installers need a token or password, and it follows that branch. Hand its token to testers from the channel page. To keep all feature branches on one channel instead, create a channel such as preview that follows feature/*.

  • Your plan’s channel limit applies. At the limit the build is still created, no channel is, and the job summary says so. See pricing.
  • Turn branch channels off for one workflow with create-branch-channel: false.
  • Naming a channel with the channel input also stops a separate channel being created for that branch.
  • A following channel never moves to a lower build number, so parallel jobs of the same branch cannot roll it back.
  • You can still assign an older build by hand to roll back. The next matching push replaces it.
Ship main to production by naming the channel
      - uses: Ivorn-Technologies/upload-build@v1
        if: github.ref == 'refs/heads/main'
        with:
          api-key: ${{ secrets.OMNIHORN_API_KEY }}
          path: ./dist
          channel: production

Tags and release versions

A tag build creates no branch channel and follows nothing. Name a channel with the channel input to ship it. The example below turns the tag v1.4.0 into the base version 1.4.0; Omnihorn appends +build.N.

Release from a tag
on:
  push:
    tags: ['v*']

jobs:
  release:
    runs-on: windows-latest
    steps:
      - uses: actions/checkout@v4
      - name: Build
        run: echo "Replace this step with your build command"
      - id: version
        shell: bash
        run: echo "value=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
      - uses: Ivorn-Technologies/upload-build@v1
        with:
          api-key: ${{ secrets.OMNIHORN_API_KEY }}
          path: ./dist
          version: ${{ steps.version.outputs.value }}
          channel: production

Several apps in one repository

Create one key per app and store each as its own secret. A matrix job then picks the right secret for each app. Parallel jobs that upload to the same app are fine too: each build gets its own number.

Matrix of two apps
jobs:
  release:
    runs-on: windows-latest
    strategy:
      matrix:
        include:
          - app: editor
            key: OMNIHORN_API_KEY_EDITOR
          - app: viewer
            key: OMNIHORN_API_KEY_VIEWER
    steps:
      - uses: actions/checkout@v4
      - name: Build
        run: echo "Build ${{ matrix.app }} into dist/${{ matrix.app }}"
      - uses: Ivorn-Technologies/upload-build@v1
        with:
          api-key: ${{ secrets[matrix.key] }}
          path: dist/${{ matrix.app }}

GitLab and other CI systems

The same upload runs as a command-line tool on Node 20.10 or later. Download dist/cli.js from the action repository and give the job the key as the environment variable OMNIHORN_API_KEY. Run it with --help to see every option.

.gitlab-ci.yml
release:
  image: node:22
  rules:
    - if: $CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH =~ /^release\//
  script:
    - ./build.sh   # puts the app into ./dist
    - curl -fsSL https://raw.githubusercontent.com/Ivorn-Technologies/upload-build/v1/dist/cli.js -o omnihorn-upload.js
    - node omnihorn-upload.js --path ./dist
# OMNIHORN_API_KEY is a masked CI/CD variable (Settings → CI/CD → Variables);
# GitLab passes it to the job as an environment variable.

On GitLab CI the branch, tag, commit, title, user and pipeline URL are read from GitLab’s own variables. On any other CI, pass the branch (or tag) and the commit as options. Store the key as a secret variable named OMNIHORN_API_KEY in each of them.

Any CI
node omnihorn-upload.js --path ./dist --provider other --branch "$BRANCH" --commit "$SHA"
bitbucket-pipelines.yml
pipelines:
  branches:
    main:
      - step:
          image: node:22
          script:
            - ./build.sh   # puts the app into ./dist
            - curl -fsSL https://raw.githubusercontent.com/Ivorn-Technologies/upload-build/v1/dist/cli.js -o omnihorn-upload.js
            - node omnihorn-upload.js --path ./dist --provider other --branch "$BITBUCKET_BRANCH" --commit "$BITBUCKET_COMMIT"
azure-pipelines.yml
trigger:
  branches:
    include: [main, release/*]

pool:
  vmImage: ubuntu-latest

steps:
  - script: ./build.sh   # puts the app into ./dist
    displayName: Build
  - script: |
      curl -fsSL https://raw.githubusercontent.com/Ivorn-Technologies/upload-build/v1/dist/cli.js -o omnihorn-upload.js
      node omnihorn-upload.js --path ./dist --provider other --branch "${BUILD_SOURCEBRANCH#refs/heads/}" --commit "$BUILD_SOURCEVERSION"
    displayName: Upload to Omnihorn
    env:
      OMNIHORN_API_KEY: $(OMNIHORN_API_KEY)   # a secret variable must be mapped explicitly
.circleci/config.yml
version: 2.1

jobs:
  release:
    docker:
      - image: cimg/node:22.0
    steps:
      - checkout
      - run: ./build.sh   # puts the app into ./dist
      - run: |
          curl -fsSL https://raw.githubusercontent.com/Ivorn-Technologies/upload-build/v1/dist/cli.js -o omnihorn-upload.js
          node omnihorn-upload.js --path ./dist --provider other --branch "$CIRCLE_BRANCH" --commit "$CIRCLE_SHA1"

workflows:
  release:
    jobs:
      - release:
          filters:
            branches:
              only: [main, /release\/.*/]
Jenkinsfile (multibranch pipeline)
pipeline {
  agent { docker { image 'node:22' } }
  environment {
    OMNIHORN_API_KEY = credentials('omnihorn-api-key')   // a "secret text" credential
  }
  stages {
    stage('Build') {
      steps { sh './build.sh' }   // puts the app into ./dist
    }
    stage('Upload') {
      when { anyOf { branch 'main'; branch 'release/*' } }
      steps {
        sh 'curl -fsSL https://raw.githubusercontent.com/Ivorn-Technologies/upload-build/v1/dist/cli.js -o omnihorn-upload.js'
        sh 'node omnihorn-upload.js --path ./dist --provider other --branch "$BRANCH_NAME" --commit "$GIT_COMMIT"'
      }
    }
  }
}

Keeping keys safe

  • Keep the key in a secret and never print it. The upload sends it only in an HTTP header to the Omnihorn API, never to storage or in a URL, and never logs it.
  • Pull requests from forks get no secrets. The upload is then skipped with a notice and the step succeeds, so forks do not fail your checks.
  • Branch channels are private, so a build of an unfinished branch is not open to everyone.
  • To rotate a key, create a new one, update the secret, then revoke the old one.
  • If a key leaks, revoke it at once, then check the Builds and Channels tabs. A leaked key can publish builds to one app.

Troubleshooting

You seeMeaning and fix
The API key was refused (401)The secret is empty, mistyped, or the key was revoked. Create a new key and update OMNIHORN_API_KEY.
This app is suspended or workspace is suspended (403)Contact Omnihorn support.
Retries with "Too many requests" (429)More than 60 requests a minute with one key. The upload waits and retries. Use one key per repository.
Storage limit reachedYour plan’s storage is full. Files uploaded before do not count again. Deleting builds you no longer need gives their space back within minutes.
"No branch channel was created: the plan’s channel limit is reached"The build exists, unassigned. Delete a channel, make an existing channel follow the branch, or upgrade.
No channel was created and no noteA channel follows the branch already, you set channel or create-branch-channel: false, or it is a tag build.
"channel … unchanged (a newer build is already there)"A later push of the same branch finished first. Nothing to do.
A branch channel disappearedAutomatically created private channels are removed after a period with no push and no update check. The next push creates it again. Use Keep this channel on the channel page to keep one.
"still processing after 15 minutes"Large builds take longer to scan. The build may still finish; check the Builds tab, or set wait: false.
HASH_MISMATCH or SIZE_MISMATCHA file changed while it was being uploaded. Re-run the job.
FILE_RECLAIMEDA file the build reused was cleaned up from storage in the meantime. Re-run the job; the file is uploaded again.
BUILD_BUSY (503)Another upload or a cleanup held the app for a moment. The upload retries by itself; if it still fails, re-run the job.
Fork or Dependabot run skippedExpected: those runs get no secrets.

More cases are on the troubleshooting page.

›Can I upload without GitHub Actions?

Yes. Upload from the dashboard with drag and drop, or run the same command-line tool the action uses from any CI (see the GitLab example above).

›Does a CI upload also build the Windows installer?

No. CI uploads your app’s files as a build. The installer is built on request with Build installer on the Installer tab, and it fetches the current build of its channel when it runs.

›How do I ship main to production automatically?

Nothing to do for a new app: its first channel follows main and master from the start. For another channel, open it and set Follow a branch to main, or add channel: production to the upload step with an if on the main ref.

›Can one key upload to several apps?

No. Each key belongs to one app. For several apps in one repository, create one key per app and pick the secret in a matrix.

Something wrong or missing? Tell us.