CI/CD¶
Four workflows: ci.yml and docs-check.yml check development changes, release-musl.yml
builds and ships releases, and docs.yml deploys this site.
Development checks¶
ci.yml runs on pushes to develop that touch more than docs and Markdown, on pull requests
targeting develop, and on manual dispatch:
- test — ShellCheck over the root-run scripts, then
cargo test --workspace --locked --no-fail-faston the latest stable with the required native libraries and unprivileged ICMP sockets enabled. - workflow lint — zizmor over
.github/workflows/.
docs-check.yml runs mkdocs build --strict when docs/ or mkdocs.yml change.
scripts/release.sh will not tag a commit whose ci.yml run on develop is missing, still
running or not green.
Device-level integration and leak testing runs on the maintainers' own lab rigs and is not part of the repository or these workflows. Cross-compilation proves a target builds, not that it works on a router. Device validation must identify the tested package and scenarios, including any skipped or inconclusive cases.
Release¶
release-musl.yml, triggered by pushing a v* tag, or manually with a tag input.
Manual dispatch is a dry run of the same pipeline from that tag: it builds, packages, and builds
and smoke-tests an unsigned feed, then stops. The release and publish-feed jobs are gated
on startsWith(github.ref, 'refs/tags/'), which a workflow_dispatch run does not satisfy, and
only a tag run signs.
1. Verify release invariants¶
Everything else depends on this job, so a release that violates any of it never starts building:
- the tag exists
nym-vpn-core/Cargo.tomlat that tag has aversionmatching the tag minus itsv- the tagged commit is an ancestor of both
origin/developandorigin/openwrt CHANGELOG.mdat that tag has a## [VERSION]section
It also decides whether this is a pre-release: any version with a - suffix (1.36.0-rc1).
2. Build¶
One job per target, checked out at the tag. Each runs in a pinned
image, checks the ELF imports, and uploads nym-vpnd-{arch}, nym-vpnc-{arch},
nym-vpn-{arch}.tar.gz and SHA256 sums.
| Target | Image | Toolchain |
|---|---|---|
| x86_64, i686, aarch64, armv7 | messense/rust-musl-cross, pinned by digest |
the image's stable |
| mips, mipsel, riscv64, armv5te | docker/tier3-musl/Dockerfile.*, built per run |
RUST_NIGHTLY (scripts/versions.sh) |
Each sets up an 8 GB swap file first: LTO linking needs it and the runners do not have the RAM.
3. Package¶
One job that runs build-ipk.sh then build-apk.sh for each OpenWrt architecture listed under
its binary (a table in that step), against the tag's luci-app-nym-vpn/, and fails unless there is
one .ipk and one .apk per architecture:
| Binary | OpenWrt architectures |
|---|---|
| aarch64 | aarch64_generic, aarch64_cortex-a53, aarch64_cortex-a53_neon-vfpv4, aarch64_cortex-a72 |
| x86_64 | x86_64 |
| i686 | i386_pentium4, i386_pentium-mmx |
| armv7 | arm_cortex-a5_vfpv4, arm_cortex-a7, arm_cortex-a7_neon-vfpv4, arm_cortex-a7_vfpv4, arm_cortex-a8_vfpv3, arm_cortex-a9, arm_cortex-a9_neon, arm_cortex-a9_vfpv3-d16, arm_cortex-a15_neon-vfpv4 |
| mips | mips_24kc |
| mipsel | mipsel_24kc, mips_siflower |
| riscv64 | riscv64_riscv64 |
| armv5te | arm_arm926ej-s |
Output is nym-vpn_{version}_{openwrt_arch}.ipk and .apk, uploaded as one packages
artifact.
4. Build and smoke-test the feed¶
Before anything is public. generate-feed.sh sorts packages into per-arch directories by
parsing the architecture out of each filename, then builds an index per directory:
- opkg —
PackagesandPackages.gz, signed with usign/signify toPackages.sig - apk —
packages.adb, built byapk mkndx, with each package under the nameapkfetches (nym-vpn-<ver>-r0.apk) and the oneinstall.shlinks to (nym-vpn_<ver>_<arch>.apk)
Signing keys come from the DIAL0UT_OPKG and DIAL0UT_APK secrets, written to /tmp and removed
in an always() step straight after indexing. The job then fails unless every architecture has
an index (and, on a tag, a Packages.sig), and runs a real apk client against the feed served
locally: apk update && apk fetch --arch <arch> nym-vpn for every architecture, trusting only
the public key shipped on-device. The feed is uploaded as the feed artifact.
5. GitHub release¶
Tagged commits only. The release body's changelog is extracted from CHANGELOG.md — an awk
pass that prints the ## [VERSION] section up to the next ## [. There is no commit-message
parsing. An empty extraction fails the job, which is the second place a missing changelog section
gets caught.
Marked prerelease if verify said so. Attaches all 8 daemon binaries, 8 CLI binaries, tarballs,
checksums, every IPK and APK, and scripts/install.sh.
6. Publish the feed¶
Tagged non-pre-release commits only, after the release exists, one at a time (a publish-feed
concurrency group). It checks the remote tags again and publishes only if this tag is still the
newest release, so a re-run of an old release or two tags in quick succession cannot roll the feed
back.
scripts/feed/publish-r2.sh then uploads the feed to Cloudflare R2, then install.sh and a
latest file holding the version tag — which is how the installer finds the current release — and
only then deletes whatever under opkg/ and apk/ this release does not have. Installs keep
working throughout. It finishes by checking that R2 holds exactly the feed that was built, keys
and sizes, and refuses to publish an empty feed. Nothing else in the bucket (toolchains/) is
touched.
It does not use aws s3 sync --delete: sync assumes both listings come back in byte order, R2
lists Packages after Packages.gz and Packages.sig, and v1.35.0's sync uploaded and deleted
each Packages at once, losing it in 12 of the 21 opkg directories.
packages.dial0ut.org/
├── install.sh
├── latest
├── opkg/
│ ├── aarch64_generic/
│ │ ├── nym-vpn_1.33.1_aarch64_generic.ipk
│ │ ├── Packages
│ │ ├── Packages.gz
│ │ └── Packages.sig
│ └── ...
└── apk/
├── aarch64_generic/
│ ├── nym-vpn-1.33.1-r0.apk
│ ├── nym-vpn_1.33.1_aarch64_generic.apk
│ └── packages.adb
└── ...
Pinned toolchains¶
A release builds with exactly what is pinned below. Nothing on the build path floats, and nothing bumps itself: every pin changes by hand.
| What | Where |
|---|---|
| Actions | SHA in each uses: |
| Tier 3 base images | digest in docker/tier3-musl/Dockerfile.* |
| mips/mipsel GCC toolchains | TOOLCHAIN_SHA256 in their Dockerfiles |
| Tier 2 images | digest in the build matrix of release-musl.yml |
| Tier 3 nightly | RUST_NIGHTLY in scripts/versions.sh |
| Alpine (apk mkpkg, mkndx, smoke test) | ALPINE_IMAGE in scripts/versions.sh |
CI does not cross-build, so a bumped pin is first built by the next release, or locally.
The test job alone runs on the latest stable, as an early warning for the next toolchain.
Feed signing¶
The two formats sign differently, so each package ships its own public key — copied into the
keystore at build time by build-ipk.sh / build-apk.sh, not in postinst.
opkg uses usign/signify (Ed25519). scripts/feed/dial0ut.pub is installed to
/etc/opkg/keys/ under a filename that is the key's fingerprint, because that is how usign
looks keys up.
apk uses an ECDSA P-256 key via apk mkndx --sign-key, and matches a signature to a key in
/etc/apk/keys/ by basename. So the CI private key and the shipped public key must share the
name dial0ut-apk.pem. Get that wrong and every install fails verification — which is exactly
what happened before v1.30.1, when the signify key was shipped to apk's keystore instead.
The apk repository line must point directly at the index (.../apk/$ARCH/packages.adb), not at
the directory. Given a bare directory, apk appends $ARCH/APKINDEX.tar.gz and 404s.
Release artifacts¶
| Artifact | Count | Example |
|---|---|---|
| Daemon binaries | 8 | nym-vpnd-aarch64 |
| CLI binaries | 8 | nym-vpnc-aarch64 |
| Tarballs | 8 | nym-vpn-aarch64.tar.gz |
| SHA256 checksums | 24 | nym-vpnd-aarch64.sha256 |
| IPK packages | 21 | nym-vpn_1.33.1_aarch64_generic.ipk |
| APK packages | 21 | nym-vpn_1.33.1_aarch64_generic.apk |
| Install script | 1 | install.sh |
Docs¶
docs.yml runs mkdocs gh-deploy --force --strict, with the same docs/requirements.txt as
docs-check.yml, on any push to the openwrt branch that touches docs/** or mkdocs.yml.
Pushing docs changes to develop deploys nothing.