This repository builds the public, signed Alpine APK feed for independently installed Couch integrations. It is intentionally separate from the Couch runtime source so a reviewed integration can ship without a complete runtime release.
source-pin.json records one immutable tooling pin and one
immutable repository and commit for each curated integration. Denon is sourced
from its own repository; Couch supplies the shared SDK, host protocol, package
builder, and package-store tests. Admission checks out every exact object,
validates each repository's integration.json, runs its locked test suite,
builds ARMv7 payloads, and exercises the native package path. Changing any pin
therefore requires the same reviewable evidence as a package change.
The Couch repositories moved from dangerouslaser to the
Couch-OS organization in September 2026, and a
pin may name either owner with exactly that casing. Denon is pinned at
Couch-OS/couch-integration-denon,
and the Couch tooling pin now names
Couch-OS/couch at the same commit. GitHub
redirects a transferred Git URL only until a repository exists again at the old
name, so a pin must not rely on that redirect. This feed repository moved on
2026-09-18.
preview currently publishes the Denon, Hue, Kodi, Sonos and LG webOS
integrations. Synthetic or test-only sources are rejected even if a policy
tries to select them.
stable is deliberately an empty signed index. It contains no integration
packages until a production-tier integration has validated hardware evidence.
The stable index is valid but has no installable packages.
The repository URLs are:
https://packages.couch-os.dev/preview
https://packages.couch-os.dev/stable # intentionally empty
packages.couch-os.dev is this repository's GitHub Pages custom domain. Before
the move the same feed was served at
https://dangerouslaser.github.io/couch-integrations/{preview,stable}; that
address no longer exists, because a Pages address follows its owner and is not
forwarded. Couch fetches indexes with redirects off, so runtimes that know only
that address (up to v0.1.0-alpha.20260918.175.dev) cannot browse, install or
update packages until they update; their installed integrations keep working,
and the system updater is unaffected. Later runtimes try
packages.couch-os.dev first. The published site uses only relative links.
The Couch installer appends armv7 when it fetches APKINDEX.tar.gz.
keys/couch-integrations.rsa.pub is the
public half of the protected APK_SIGNING_KEY GitHub environment secret. The
publish job derives the public key from that secret and compares its DER form
before it signs anything. A key mismatch intentionally makes validation fail.
The published public key is available at https://packages.couch-os.dev/preview/couch-integrations.rsa.pub. Its PEM-file SHA-256 fingerprint is:
80f3a73d86759cda103cb4f9a876cd4caee9d25c235c6d782b4be8a900b2696c
Provision this public key at
/opt/couch/integration-keys/official/couch-integrations.rsa.pub on an
integration-capable runtime before using the feed. Verify it against the
fingerprint above, obtained through a trusted source. Never put the private PEM in this
repository, an artifact, a pull-request workflow, or a package.
The Alpine index is signed, but it never expires and it has no order. Someone sitting between a remote and this feed could keep serving an old, validly signed index for ever, or swap in an older one than the remote has already seen, and so hide a fixed package. A remote also used to learn that a package needs a newer Couch only after downloading it.
Each channel therefore publishes two more files beside its index:
https://packages.couch-os.dev/preview/armv7/feed.json
https://packages.couch-os.dev/preview/armv7/feed.json.sig
https://packages.couch-os.dev/stable/armv7/feed.json
https://packages.couch-os.dev/stable/armv7/feed.json.sig
feed.json says which channel it is for, when it was issued and when it
expires, carries a sequence number that only ever grows (the publish time in
seconds), names the one APKINDEX.tar.gz it belongs to by size and SHA-256, and
lists every package in that index with its size, SHA-256, protocol_version and
min_core_protocol_version. The two protocol numbers are read from the manifest
inside each published APK; an absent minimum means 1. stable has the same file
with an empty package list. feed.json.sig is a plain RSA SHA-256 signature
over the exact bytes of feed.json, made with the same key as the index and the
packages.
A remote fetches both files when it refreshes a repository and uses the index
only if the signature is good, the channel is the one it asked for, the sequence
is not lower than the last one it accepted, the metadata has not expired, and
the index it downloaded hashes to the value in feed.json. A package whose
min_core_protocol_version is newer than the remote is shown as "Needs a newer
Couch" and is never downloaded. A remote that boots with an unset clock skips
only the expiry check.
feed.json is valid for 30 days and is signed again every Monday by the
scheduled run of publish.yml, as well as on every publication. A re-signing
(scripts/resign.sh) builds nothing and needs no admission run: it restores the
newest release archive, checks that its signed feed.json still describes
exactly the index and packages beside it, and writes a new feed.json and
signature. Every package, index and receipt is republished byte for byte. A full
publication likewise keeps a channel's index when its package set did not
change, and refuses to publish a sequence that is not later than the previous
one. Each re-signing is kept as a resigned-<sequence> release holding the
complete archive (the newest eight are kept), so the next run always knows the
last published sequence; feed-<commit> releases still mark real publications.
If publishing stops for 30 days, remotes with a correct clock refuse the official feed until it is signed again. Installed integrations keep working; browsing, installing and updating packages stop. Three weekly runs can fail before that happens. Things that stop the schedule: GitHub turns a scheduled workflow off after 60 days without repository activity (re-enable it under Actions), a failing run, or a missing
APK_SIGNING_KEY. To sign again by hand:gh workflow run publish.yml --repo Couch-OS/couch-integrations --ref main
To check the published metadata yourself, with this repository's public key:
base=https://packages.couch-os.dev/preview/armv7
curl -fsSO $base/feed.json -O $base/feed.json.sig -O $base/APKINDEX.tar.gz
openssl dgst -sha256 -verify keys/couch-integrations.rsa.pub \
-signature feed.json.sig feed.json # prints "Verified OK"
sha256sum APKINDEX.tar.gz # equals .index.sha256 in feed.json
python3 -m json.tool feed.jsonFeed admission runs on every pull request, and a pull request run has no
secrets at all. The one exception to "the build has no secrets" is narrow and
applies to main only; see Build-time secrets. Its final
job is exactly named admission, the check to require in this repository's
branch ruleset. It has three layers:
- all source pins and channel policy;
- each independent repository's locked admission tests plus the pinned shared host protocol and package-store tests;
- ARMv7 builds, QEMU Alpine APK build/index/install, and immutable provenance and tamper rejection.
The unsigned ARM payload is uploaded only as a short-lived review artifact. It cannot sign or deploy a feed.
Publish signed feed runs from main only. Its
package-signing environment restricts its APK_SIGNING_KEY secret to main.
To publish, the workflow requires a successful Feed admission run for the exact main
commit and downloads that run’s unsigned payload artifact. It does not rebuild
or execute the integration while the signing key is present. The job
checks the public/private key match, preserves all previously published APKs,
signs the preview index and each channel's feed metadata,
creates a GitHub Release archive for each feed revision, and uploads the complete site through GitHub's official Pages
artifact/deployment workflow. Its weekly scheduled run only signs the feed
metadata again.
Until September 2026 the rule was "the build job has no secrets". It is now:
a build secret exists only if build-secrets.json names
it, only the main branch can read it, and only the compile of the one
integration it is granted to ever sees it. The signing key is a different
secret in a different job and environment, and that has not changed: the job
that compiles never signs, and the job that signs never compiles.
The allowlist today is one entry. The Sonos package has the project's Sonos developer API key compiled in, so a remote identifies itself to players without a key file on the device:
{ "schema": 1, "integrations": { "sonos": ["COUCH_SONOS_BUILT_IN_API_KEY"] } }- Only this repository grants a secret. An integration repository cannot ask
for one:
integration.jsonkeeps exactly nine keys and none of them is about secrets. A name must sit in its own integration's namespace (COUCH_<ID>_...), so an entry can never stand in forPATH,RUSTFLAGS, a token, or another integration's secret. Adding an entry is a reviewed change to this file and to the twoenv:blocks inadmission.ymlthat map it; a test keeps the two in step. - Where it lives. The value is a secret of the
package-buildGitHub environment, which admits themainbranch only. Thebuild-artifactjob names that environment only for amainpush or amaindispatch. A pull request, from a fork or from this repository, runs the same job with no environment, so the secret is empty whatever the pull request changes in the workflow or the scripts. It is not a repository secret, and it is not inpackage-signing. - What sees it.
scripts/build_with_secrets.shfirst fetches the locked dependencies of the allowlisted integrations with no secret in the environment. A second step, the only build step given the secret, removes every allowlisted name from its environment, holds the values in unexported shell variables, and exports each one solely tocargo build --locked --offlineof the integration it belongs to. Every other integration is compiled afterwards byscripts/build_artifact.sh, a step that is never given a secret and refuses to run if it finds one. Integration tests run in a different job (source-admission) that has no environment. - Never echoed. No script traces its commands, and the secret-handling one
refuses to run under
sh -x. The value is registered with::add-mask::. The compiler output of the secret-bearing build is held back, searched for the value, and printed only if it is clean. Cargo'starget/directory, whose dependency files record the build environment, is deleted as soon as the binary is copied out, and the job has no cache. - Checked afterwards. The build fails unless a supplied secret is found
verbatim in its integration's binary (so a misspelt name cannot ship a keyless
package), and a last step,
scripts/build_secret_guard.py payload, fails the job if the value appears in any other file of the payload: another integration's binary, a manifest, a provenance receipt, the pin snapshot or the checksum list. A value must be 16 to 512 printable ASCII characters with no whitespace, so it can be searched for exactly. - A pull request still passes; a publishable build cannot skip the key. With
no secret, the integration compiles its own placeholder and its receipt records
"built_with_secrets": []. Onmainthe job setsCOUCH_FEED_REQUIRE_BUILD_SECRETS=1: a missing or empty secret fails the build before anything is compiled, so admission fails and nothing is published. The receipt of a publishable build records the names, never the values:"built_with_secrets": ["COUCH_SONOS_BUILT_IN_API_KEY"]. The signing job checks that list again (scripts/validate_payload.py) and refuses a payload whose allowlisted integration was built without every one of its secrets. Onlyscripts/test_publish.shmay sign a placeholder build, with a disposable key:publish.shrefuses that review mode whenever the checkout still trusts the production public key. Receipts of integrations that are not in the allowlist are unchanged and never carry the field.
- The key is in the published binary. Anyone can download the signed APK, or
the unsigned payload artefact of a
mainadmission run, and read the key out of it. That is inherent in shipping a built-in key. The mechanism keeps the key out of repositories, pull requests, logs and every other artefact; it does not make the key confidential. Treat it as a project identifier that can be revoked, not as a credential that guards anything. - Pinned third-party build code runs with the secret. The secret-bearing
compile runs the build scripts and procedural macros of the integration and of
every dependency in its
Cargo.lock, with the secret in their environment. It is locked, pinned by commit, reviewed when the pin moves, and told to stay offline, but--offlinebinds Cargo, not a hostile build script, and the runner has a network. Review the lock file diff of an allowlisted integration with that in mind. - One job, one machine. Other integrations are compiled later in the same
job. They are not given the secret and no running process holds it by then,
but a GitHub-hosted runner gives every process
sudo, so deliberately hostile code in any integration pinned by this feed could still dig it out of the runner. Such code could already ship a malicious binary to every remote; the review of source pins is the defence against both. - Reviewed code on
mainis trusted. Anyone who can merge a workflow change tomaincan read the environment's secrets. Branch protection is the control.
The package-build environment must exist, restricted to main, before an
allowlisted integration is pinned (GitHub otherwise creates it unrestricted the
first time the job names it). It has no required reviewers: a reviewer would
hold up every main admission run, and with it automatic publication.
gh api -X PUT repos/Couch-OS/couch-integrations/environments/package-build --input - <<'JSON'
{"deployment_branch_policy": {"protected_branches": false, "custom_branch_policies": true}}
JSON
gh api -X POST repos/Couch-OS/couch-integrations/environments/package-build/deployment-branch-policies \
-f name=main -f type=branchSet the value without a trailing newline:
tr -d '[:space:]' < sonos-api-key | gh secret set COUCH_SONOS_BUILT_IN_API_KEY \
--repo Couch-OS/couch-integrations --env package-buildPublished bytes are immutable, and the key is part of the Sonos binary. A new
key therefore needs a new Sonos version: the main build with the new key no
longer matches the published receipt of the current version, and publication
stops with "immutable provenance differs" until the version moves. Rotate in
this order:
- merge a version bump in the Sonos integration repository;
- set the new secret value;
- merge the feed pull request that moves the Sonos pin to that commit.
Between steps 2 and 3 any other feed merge fails to publish, harmlessly, and step 3 repairs it. Packages already published keep the old key for as long as remotes have them installed, so revoke the old key at Sonos only once the new version has had time to reach them.
Each existing couch-integration-ID-VERSION-r0.apk is treated as immutable:
publishing the same path with different bytes fails. Older packages remain in
the Pages package set and in the release archive so a Couch slot can roll back.
Materialize the exact source graph, then validate it:
scripts/checkout_sources.sh ../integration-sources
python3 scripts/validate_feed.py --sources ../integration-sources
python3 -m unittest discover -s tests -v
sh -n scripts/checkout_sources.sh scripts/build_with_secrets.sh scripts/build_artifact.sh scripts/publish.sh scripts/resign.shTo build the unsigned payload the way admission does, with no secrets (an allowlisted integration then carries its placeholder and cannot be published):
scripts/build_with_secrets.sh fetch ../integration-sources
scripts/build_with_secrets.sh compile ../integration-sources ../secret-stage
scripts/build_artifact.sh ../integration-sources ../payload ../secret-stage
python3 scripts/build_secret_guard.py payload ../payloadThe signed build needs Docker with ARMv7 QEMU support, Alpine abuild tools,
and a private key that matches the committed public key. Use the protected
workflow for real publishing.
The released .170 runtime predates the package host. Install an
integration-capable runtime before running these commands inside Alpine
(the normal Couch SSH shell):
/opt/couch/runtime/current/couch-confd integrations \
install-repository couch-integration-denon \
--repository https://packages.couch-os.dev/previewFor your own feed, provision its public key in a separate directory and name both explicitly:
/opt/couch/runtime/current/couch-confd integrations \
--keys-dir /opt/couch/integration-keys/custom/my-feed \
install-repository couch-integration-YOUR_ID \
--repository https://packages.example.invalid/couchFor a local development build, copy the signed APK over SSH and use
install-sideload /tmp/package.apk with the development key directory.
Repository URLs are currently supplied per invocation; there is no saved
repository registry or repository-management UI. Direct apk add bypasses
Couch validation and activation and is not the integration installation path.
See the developer packaging guide.
Each integration owns its source, lock file, integration.json, runtime
manifest, and admission suite in its pinned repository. The
Couch repository owns the reusable
SDK, protocol, admission harness, and package tooling. This repository owns the
reviewed source graph, distribution policy, and publishing workflow.
Publication is automatic. When a reviewed feed change is merged and its
main-branch Feed admission run succeeds, publish.yml starts by itself, takes
the payload that run built, signs the indexes and deploys the site. A merge that
changes none of the published inputs (source-pin.json, feed-policy.json,
build-secrets.json, feed/, keys/ and the build and publish scripts) is skipped, so a README or
test change does not re-sign anything. The automatic run uses this workflow as
it is on main, never a pull request's copy, and still refuses unless the
admission run passed for exactly the commit being published.
To republish a main commit by hand:
gh workflow run publish.yml --repo Couch-OS/couch-integrations \
--ref main -f admission_run_id=SUCCESSFUL_MAIN_ADMISSION_RUN_IDThe payload artifact of an admission run is kept for 14 days; after that, run
Feed admission on main again first (gh workflow run admission.yml --ref main) and use the new run. Without admission_run_id the same command only
signs the feed metadata again, which needs no payload.
Merging to main is therefore the last human decision before the signing key is
used. For one more, add required reviewers to the package-signing environment
(Settings → Environments): each signing job then waits for an approval click.
Require admission from GitHub Actions (app ID 15368) in branch protection,
with up-to-date branches and administrators included. The signing, build-secret
(package-build) and Pages environments permit main only. Repeating publication with the same admitted
artifact reuses existing APK bytes, and reuses each signed index whose package
set its previous feed metadata already describes; only feed.json and its
signature are new. Changed
binary or manifest bytes at an existing version require a version bump. A
retained historical package keeps its original source, SDK, and tooling receipt
even when a later feed revision advances those pins, or names the same
repository under the other allowed owner after a move from dangerouslaser to
Couch-OS. A receipt that names a differently named repository, or whose
package identity or bytes differ, still stops publication.