owfeed
Русская версия
One binary. Turns a directory into a signed feed for both release lines — apk for OpenWrt 25.12
and later, opkg for 24.10 and earlier — so your users run three lines and apk add works.
A noarch package across all 35 architectures — built, signed, indexed, laid out — takes ~25 seconds.
The status quo is 35 SDK builds.
New here? The cookbook walks the whole path — build a package,
test it on a real router, sign it, get it into a feed — with files you can copy.
Install
go install github.com/VizzleTF/owfeed/cmd/owfeed@latest
Or a release binary, checked against the attestation GitHub's own workflow produced for it — not
against a checksum from the same release, which whoever replaced the binary could replace too:
gh release download v0.1.7 -R VizzleTF/owfeed -p 'owfeed-linux-amd64'
gh attestation verify owfeed-linux-amd64 -R VizzleTF/owfeed \
--signer-workflow VizzleTF/owfeed/.github/workflows/release.yml
chmod +x owfeed-linux-amd64 && sudo mv owfeed-linux-amd64 /usr/local/bin/owfeed
In GitHub Actions, VizzleTF/owfeed/setup@v0.1.7 does that verification for you. Builds exist for
linux and darwin, amd64 and arm64.
What it needs. Nothing for build, sign, index or publish: the apk toolchain is fetched
from the OpenWrt SDK and verified against a pinned signing key, so there is no toolchain to install.
smoke needs Docker, because installing on a real OpenWrt image is the whole point of it. On macOS
build needs Docker too — apk-tools does not build there.
Do you need a feed at all?
Probably not, and this says so before it sells you anything.
A key in /etc/apk/keys is a trust anchor for every package name, not just yours. A feed whose
key leaks can offer a higher version of dropbear and win the resolution, and apk has no
revocation — no CRL, no expiry, no way to say a key is dead.
One package people install occasionally: publish signed release artifacts instead.
owfeed release does exactly that — packages plus a signed manifest — and asks your users for
nothing but a signature check. Skip the feed.
Several packages, or you want apk upgrade to work: a feed is the only thing that does that,
because apk upgrades from an index and nothing else. Then read on.
Why not openwrt/gh-action-sdk?
Because it is a compiler and this is a publisher. They do different halves, and most third-party
packages only need the second.
|
gh-action-sdk |
owfeed |
| what it does |
builds a package from source, in the SDK |
packages a directory that is already built |
| noarch across 35 architectures |
35 SDK builds |
one, ~25 seconds |
| index |
— |
signed, apk and opkg both |
| signing |
PRIVATE_KEY written into $TOPDIR |
key never enters the build job |
| publishing |
— |
GitHub Pages, gated |
| proof it installs |
— |
smoke, on a real OpenWrt image, failing if apk wants --allow-untrusted |
| proof the live feed works |
— |
verify, over the documented URL |
| release lines |
one per run |
apk and opkg from one config, each signed with the scheme its manager verifies |
| config |
workflow inputs |
owfeed.yml, with a published schema generated from the code |
If your package is compiled C, you want the SDK — and owfeed will happily package what it
produced. If it is a LuCI theme, a LuCI app, a script or a static binary, the SDK is 35 builds to
produce something that did not need compiling.
How it works
A feed is a directory of files served over HTTPS. No server software, no database. You produce
the directory; anything that can serve static files will do.
feed/
podkop.pem your PUBLIC key
releases/25.12/x86_64/
packages.adb the signed index (binary)
index.json the same thing as JSON
sha256sums
podkop-0.28-r1.apk the packages, flat, beside the index
luci-app-podkop-0.28-r1.apk
releases/25.12/aarch64_cortex-a53/ the same files again
… 35 directories, one per architecture
On the router, four things happen:
- The user drops your
.pem into /etc/apk/keys/. The router now trusts your signature.
- They write one line into
/etc/apk/repositories.d/<feed>.list — the direct URL of packages.adb.
apk update fetches that index and verifies it against your key.
apk add podkop finds podkop 0.28-r1 in the index, derives the filename from the name and
version, fetches podkop-0.28-r1.apk from the same directory, checks it against the hash the
index recorded, and installs it.
Where does it install to? A package's payload is a piece of the root filesystem. What you put
in staging/podkop/etc/config/podkop arrives at /etc/config/podkop.
Why 35 copies of the same file? In apk's ndx mode a router reads exactly one index — the one
for its own architecture. A noarch package has to be in all of them. The copies are byte-identical.
The stages map onto that one to one:
|
|
build |
your staged directory → .apk files |
sign |
a signature into each .apk |
index |
fan out into the 35 directories, write a signed packages.adb in each |
publish |
check it all, hand the directory to whatever uploads it |
Two release lines, two package managers. 25.12 and later is apk; 24.10 and earlier is opkg, and
they agree on almost nothing — a different index, a different signature scheme, a different word for
"architecture-independent". owfeed publishes either or both from one config, and holds both to the
same checks.
|
25.12+ (apk) |
24.10 and earlier (opkg) |
| index |
binary packages.adb |
text Packages + Packages.gz |
| signed over |
the index itself |
the uncompressed Packages |
| signature |
EC prime256v1 |
usign / ed25519 |
| repository line |
URL of the index file |
URL of the directory |
| key installed as |
/etc/apk/keys/<any name>.pem |
/etc/opkg/keys/<key id> |
| per-package signature |
yes |
none — trust rests on the index |
| "any architecture" |
noarch |
all |
A feed serving both signs with two keys. That is not a choice: each manager verifies only its own
scheme.
I want to publish a feed
owfeed init --url https://feed.example.org
owfeed keygen -o ~/keys/myfeed.pem # outside the repo. A published key cannot be revoked.
export OWFEED_SIGN_KEY="$(cat ~/keys/myfeed.pem)"
# edit owfeed.yml: point `files:` at a directory laid out the way it should install
owfeed lock --update # derives the architecture list; commit it
owfeed build && owfeed sign && owfeed index
owfeed doctor
out/ is now the feed. Upload it.
I want to ship a new version
Bump version: in owfeed.yml, then:
owfeed build && owfeed sign && owfeed index && owfeed doctor
I want to add a package
Add another entry under packages:. Same four commands.
I want a package on one line only
releases:
- line: "25.12"
default: true
format: apk
- line: "24.10"
format: ipk
packages:
- name: luci-app-mine # no `releases:` — goes to both
...
- name: luci-app-mine-new
releases: ["25.12"] # 25.12 only
...
One owfeed build produces every line, in each line's format. One owfeed index publishes them all.
I want to tell users how to install it
owfeed install-snippet # markdown, paste into your README
owfeed install-snippet --format sh # just the commands
Do not write your own. doctor compares your README against this output, because a feed whose
documented URL 404s is a live bug in a major feed right now.
I want this in CI
The whole thing, correctly shaped, is one job:
jobs:
publish:
permissions: # a called workflow can only narrow these, never widen them,
contents: read # so the caller has to grant what the publish job needs
pages: write
id-token: write
actions: read
uses: VizzleTF/owfeed/.github/workflows/feed.yml@v0.1.7
with:
owfeed-version: v0.1.7
smoke-releases: "25.12 24.10"
secrets:
sign-key: ${{ secrets.OWFEED_SIGN_KEY }}
usign-key: ${{ secrets.OWFEED_USIGN_KEY }} # only if you serve 24.10
That splits build from publish so the signing key is never in the job that runs your build
scripts, gates the upload on owfeed publish, and installs the packages on a real OpenWrt image
before any of it goes out. pre-build: and post-index: take shell if your feed fetches or
checks anything of its own.
The signing secrets have to be repository or organization secrets, not environment ones: a
calling job has no environment, so it cannot read an environment secret in order to pass it on.
The environment: on the publish job is still what gates the run behind a reviewer.
If you want the steps yourself, take the tool and leave the shape:
- uses: VizzleTF/owfeed/setup@v0.1.7
with: { version: v0.1.7 }
- run: owfeed --frozen-lock build && owfeed sign && owfeed index
env:
OWFEED_SIGN_KEY: ${{ secrets.OWFEED_SIGN_KEY }}
- run: owfeed publish # refuses to publish a broken tree. No override flag.
- uses: actions/upload-pages-artifact@v3
with: { path: out }
- uses: actions/deploy-pages@v4
Put publish in a separate job with environment: so a fork PR cannot reach the key.
setup downloads one binary and checks it against GitHub's build attestation before running it —
not against a checksum file from the same release, which whoever replaced the binary could replace
too.
Upstream added an architecture
owfeed lock --update # prints the diff; commit it
--frozen-lock fails the build until you do. What your feed covers should not change without you
seeing it.
I want to be sure it installs
owfeed smoke # installs the built feed on a real OpenWrt image
doctor proves the tree is coherent. This proves a router accepts it — following your own published
snippet, and failing if apk asks for --allow-untrusted. They are different claims.
This is not owlab, and does not use it. owlab, by the same
author, is the development cycle: several releases running side by side for as long as you are
working, sources syncing into them, LuCI open in a browser. owfeed smoke is one gate before a
publish — one architecture, one install, one answer, then gone. The two are independent on purpose:
making a publishing tool depend on a development tool would make "will this feed install" depend on
whether owlab was installed correctly, for the sake of sharing some Docker invocation. Develop with
owlab if you want to; publish with owfeed either way.
They still compose, through a file format rather than a dependency: owlab build writes
dist/<arch>/ and every stage here reads it. The boundary, the invariants either side of it, and
the contracts that cross it are written down in ECOSYSTEM.md, and
STATUS.md says how much of owfeed's side of it exists today.
I want to check what is already live
owfeed verify out # fetches the published feed over its documented URL
Catches a redirect apk will not follow, a package the live index names that is missing or the wrong
size, and — given the tree about to replace it — a version being republished with different
contents.
Something broke
owfeed doctor # numbered findings, each says why it matters and what to do
Things that will bite you
owfeed refuses each of these. Every one has burned a real maintainer.
|
|
arch: all |
apk rejects it as uninstallable. Use noarch. |
| PKCS#8 key |
openssl genpkey -algorithm EC writes the wrong PEM. Only SEC1 works. |
1.0~beta |
after ~ apk reads a commit hash, so only hex. Use _beta1. |
| A feed URL that redirects |
apk does not follow 30x (openwrt#17180). |
No sysupgrade.conf line |
/etc/apk/keys/*.pem do not survive sysupgrade. Top cause of post-upgrade UNTRUSTED signature. |
| Indexing before signing |
signing appends bytes, so the index no longer matches the file. |
/etc/config/foo not in conffiles: |
sysupgrade replaces the user's settings with your defaults, silently, every upgrade. |
.po files in the payload |
LuCI reads compiled .lmo. Point i18n.from: at them and owfeed compiles them. |
| A README that drifted |
already live in a major feed today. |
| A package the build dropped |
absence is invisible to every check that reads the tree, so owfeed checks the tree against your config. |
Things owfeed will not pretend
- apk has no revocation. No CRL, no expiry, no kill signal. owfeed makes rotation cheap; it does
not claim revocation exists.
- Your key is a trust anchor for every package name, not just yours — see
Do you need a feed at all?, which is the first thing this README
says for a reason.
- Attended Sysupgrade will not carry your packages across.
owut forwards no custom repositories
and the ASU server's repository_allow_list is empty by default, which denies everything.
build packages a directory; it does not build one. Your CSS must already be built. owfeed
does compile your .po catalogues — LuCI reads .lmo and ignores .po, so that gap is silent
and worth closing — but it will not run your build for you, and it refuses to package sources.
Not there yet
Cloudflare R2 and rsync · SBOM · key rotation commands · reusing an already-published package
instead of re-signing it unchanged.
SDK builds are not on this list, and are not coming. owfeed packages, it does not compile.
Build with owlab, openwrt/gh-action-sdk, or your own SDK
call, and leave the result in dist/<arch>/ — every stage here reads that directory and asks
nothing about how the bytes were made. See the artifact contract and
ECOSYSTEM.md for where the boundary runs and why it is drawn there.
A feed built with it
VizzleTF/owfeed-packages — a live feed carrying a LuCI
theme and a static Go binary across 20 architectures. Pull requests build, sign, index and check the
whole thing with a throwaway key, so a fork never comes near the feed's own.
Someone else's CI, my feed
A feed that carries other people's packages cannot hand them its signing key, and does not have to.
Authors build and sign in their own CI and publish a signed release; the feed pulls, verifies the
author's signature against a pinned key, and adds its own.
apk signature blocks are additive, so the package a router installs carries both — the author's
signature travels all the way to the device rather than being checked at ingest and discarded.
owfeed sign # in the author's CI, with the author's key
owfeed doctor --require-origin # every package says which repository it is from
If you are the author
Your repository has an ipk line and never builds an index: the feed at the far end signs that with
its own key. What you publish is a release plus a signed inventory of it.
owfeed --frozen-lock build
owfeed release --repo "$GITHUB_REPOSITORY" --tag "$GITHUB_REF_NAME"
release writes manifest.txt — what belongs to this release, each file's size and hash — and
signs it with a usign key, the scheme OpenWrt already ships so a router can verify it with nothing
installed. The manifest records which repository it belongs to and readers check that: a signature
proves who wrote something, never what it is about, so without that line a manifest lifted from
another of your releases verifies perfectly as this one.
Release assets are flat, and an apk's filename carries no architecture — in a feed the architecture
is the directory. A package built for twenty architectures would therefore produce twenty files
with one name, so release appends the architecture where names collide, and only where they
collide: a noarch package keeps the filename an installer already on a router looks it up by.
podkop_autoupdater is a
worked example, including what its signing key does and does not vouch for.
The feed's CONTRIBUTING
walks through both sides.
Docs
License
Apache-2.0.