Cookbook · 2 of 3

Build and release

Turn a working package into signed artifacts anyone can verify. Two keys are involved and they do different jobs — that is the part worth reading before you copy the workflow.

Make the two keys, once

owfeed keygen -o author.pem       # EC prime256v1 — signs the packages
owfeed keygen --usign -o release.key   # usign/ed25519 — signs the release manifest

gh secret set OWFEED_AUTHOR_KEY < author.pem
gh secret set OWFEED_RELEASE_KEY < release.key

git add author.pub.pem release.pub && git commit -m "publish the public halves"
Commit the public halves; never the private ones. A published signing key cannot be revoked — apk has no CRL, no expiry, no way to say a key is dead. Generate them outside a git working tree; owfeed keygen refuses to write inside one without --force. Note the key ids it prints: a feed will ask for them.

Why two

KeySignsChecked by
EC (author.pem)inside each .apkanyone, at any time, against your public half
usign (release.key)the release manifesta feed, once, when it ingests your release

The usign signature is checked at ingest and then it is done — it never leaves the feed's CI. The EC signature travels inside the package all the way to the router, so somebody who does not trust the feed can still confirm you built what they installed. That is the one claim about a package that survives the feed serving it, and it is why feeds require it.

Build both release lines

owlab build --release 25.12.5    # apk, for 25.12 and later
owlab build --release 24.10.8    # ipk, for 24.10 and earlier

Both land in dist/<arch>/. An architecture-independent package produces two directories, and that is correct rather than a bug: apk requires noarch and rejects all, opkg has never heard of noarch.

dist/
├── noarch/my-app-1.0.0-r1.apk
└── all/my-app_1.0.0-r1_all.ipk

Shipping only 25.12 abandons everyone who has not upgraded — routers stay on a release for years.

Build before you release, even if sync worked. luci.mk minifies JS and CSS; owlab sync does not. Code that works unminified and breaks minified is invisible until somebody builds a package.

Sign, then write the manifest — in that order

owfeed sign --key env:OWFEED_AUTHOR_KEY
owfeed release --repo "$GITHUB_REPOSITORY" --tag "$GITHUB_REF_NAME"

The order is not a preference. owfeed release records every package's size and sha256, and signing appends bytes. Reverse them and the manifest describes files that no longer exist.

owfeed release writes manifest.txt: what belongs to this release, each file's size and hash, and which repository and tag it came from — signed with your usign key. It records the repository because a signature proves who wrote something, never what it is about. Without that line, a manifest lifted from another of your releases would verify perfectly as this one.

GitHub Actions

.github/workflows/release.yml

name: release
on:
  push: { tags: ["v*"] }
permissions: { contents: read }
jobs:
  release:
    runs-on: ubuntu-latest
    permissions: { contents: write }
    steps:
      - uses: actions/checkout@v5
      - uses: owfeed/owlab/setup@v0.5.1
      - uses: owfeed/owfeed/setup@v0.5.0

      - run: owlab build --release 25.12.5
      - run: owlab build --release 24.10.8

      - name: Sign the packages, then write the manifest
        env:
          OWFEED_AUTHOR_KEY:  ${{ secrets.OWFEED_AUTHOR_KEY }}
          OWFEED_RELEASE_KEY: ${{ secrets.OWFEED_RELEASE_KEY }}
        run: |
          owfeed sign --key env:OWFEED_AUTHOR_KEY
          owfeed release

      - run: gh release create "${{ github.ref_name }}" --generate-notes
             dist/*/*.apk dist/*/*.ipk dist/manifest.txt*
        env: { GH_TOKEN: ${{ github.token }} }

owfeed release reads the repository and tag from GITHUB_REPOSITORY and GITHUB_REF_NAME on GitHub, and from CI_PROJECT_PATH and CI_COMMIT_TAG on GitLab, so the flags are only for overriding them.

Keep the signing steps in a job that pull requests cannot reach. A fork's pull request runs your build; a secret reachable from it is a secret published. Gate on the tag, as above, or use a separate job with an environment:.

GitLab CI

release:
  rules:
    - if: $CI_COMMIT_TAG
  script:
    - wget -qO- https://github.com/owfeed/owlab/releases/download/v0.5.1/owlab_0.5.1_linux_amd64.tar.gz | tar xz
    - wget -qO owfeed https://github.com/owfeed/owfeed/releases/latest/download/owfeed-linux-amd64
    - chmod +x owfeed && export PATH="$PWD:$PATH"
    - owlab build --release 25.12.5
    - owlab build --release 24.10.8
    - owfeed sign --key env:OWFEED_AUTHOR_KEY
    - owfeed release

Check it before anyone else does

owfeed doctor --require-origin

Numbered findings, each saying what it costs and what to do. The ones that catch most first attempts:

FindingWhat it means
arch: allapk rejects it as uninstallable. Use LUCI_PKGARCH:=all and let the build translate it — that is what OpenWrt's own packaging does.
OWF207A /etc/config/* file is shipped but not in conffiles:, so sysupgrade replaces the user's settings with your defaults, silently, on every firmware upgrade.
Version rejectedAfter ~ apk reads a commit hash, so only hex. Write 1.0_beta1, not 1.0~beta.
Translations missingLuCI reads compiled .lmo and ignores .po. Point i18n.from: at your catalogues.