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
Create a key
In Omnihorn, open your app, then the CI/CD tab, and choose Create key. Copy the key.
Store it as a repository secret
In GitHub, open the repository, then Settings, Secrets and variables, Actions, New repository secret. Name it
OMNIHORN_API_KEYand paste the key.Add the workflow
Copy the workflow from the CI/CD tab into
.github/workflows/release.yml, or use the one below.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.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.
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: ./distInputs and outputs
| Input | Default | What it does |
|---|---|---|
api-key | none | The app’s key, read from a secret. Required. |
path | none | The folder whose files make up the build. Required. Symbolic links and .git are skipped. |
version | latest | Base version MAJOR.MINOR.PATCH. Omnihorn appends +build.N. Without it, the latest build’s base version is reused. |
release-notes | empty | The "What’s new" text for the build, up to 10,000 characters. You can add or edit it later on the build’s page. |
channel | none | Also assign the build to this existing channel once it is active. |
create-branch-channel | true | Create a private channel for a branch that no channel follows yet. |
wait | true | Wait until the build is active or failed, up to 15 minutes. |
| Output | What it holds |
|---|---|
build-id | The build’s id. |
version | The full version, for example 1.4.0+build.42. |
status | active, failed, or processing when wait is false. |
channels | JSON 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
channelinput 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.
- uses: Ivorn-Technologies/upload-build@v1
if: github.ref == 'refs/heads/main'
with:
api-key: ${{ secrets.OMNIHORN_API_KEY }}
path: ./dist
channel: productionTags 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.
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: productionSeveral 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.
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.
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.
node omnihorn-upload.js --path ./dist --provider other --branch "$BRANCH" --commit "$SHA"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"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 explicitlyversion: 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\/.*/]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 see | Meaning 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 reached | Your 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 note | A 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 disappeared | Automatically 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_MISMATCH | A file changed while it was being uploaded. Re-run the job. |
FILE_RECLAIMED | A 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 skipped | Expected: 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.
Keep reading
Something wrong or missing? Tell us.