Clone
2
Packaging
Gitea edited this page 2026-08-12 10:07:49 +00:00

Packaging & declarative state

Build and push a package

docker run --rm -v "$PWD:/work" -w /work \
  git.morlana.online/f.weber/aptly-deb-builder:<tag> \
  aptly-release --config nfpm.yaml -- --repo stable --distribution stable

aptly-release = aptly-pack (builds a .deb) + aptly-push (uploads it, includes it into the repo, refreshes the publish target) in one call. Everything before -- goes to aptly-pack, everything after goes to aptly-push; the built .deb files are appended automatically.

Two packaging paths, auto-detected:

  • no debian/ directory present → nfpm (nfpm.yaml, no Debian toolchain knowledge needed)
  • debian/ directory present → real dpkg-buildpackage -us -uc -b (unsigned — signing is aptly's job at publish time, not the package's)

Auth: APTLY_USER/APTLY_PASSWORD (or none at all, for an open repo) — works unchanged across all four presets from Security. Against a signed repo: do not pass --no-sign, and make sure the server has a signing key (see below); against an unsigned repo: pass --no-sign.

Reusable Gitea Action

- uses: f.weber/aptly-containerized@image/v1.6.3-1
  with:
    url: https://apt.example.com
    repo: stable
    distribution: stable
    username: ${{ secrets.APTLY_USER }}
    password: ${{ secrets.APTLY_PASSWORD }}

Lives at the repo root deliberately: uses: references to other Gitea repos with a subpath (owner/repo/path/action@ref) are not reliable, only root and local (./.gitea/actions/x) references are. A more robust fallback that needs no action resolution at all: call the image directly (see above).

Declarative state (state.yaml)

Consumed by aptly-reconcile — a single file in Compose (compose/config/state.yaml), generated from values.yaml's aptly.{localRepos,mirrors,publish} in the Helm chart. Runs exclusively against the REST API, never against the aptly CLI: aptly api serve holds the LevelDB lock, and a CLI call against the same rootDir would either block or (with -no-lock) endanger the database.

localRepos:
  - { name: stable, comment: "", defaultDistribution: stable, defaultComponent: main }
mirrors:
  - name: debian-security
    archiveURL: http://security.debian.org/debian-security
    distribution: trixie-security
    components: [main]
    architectures: [amd64, arm64]
publish:
  - name: stable-root       # only internal to the reconciler, not part of the aptly API
    prefix: ""              # "" = repo root
    distribution: stable
    sourceKind: local        # local | snapshot
    sources: [{ name: stable, component: main }]
    architectures: [amd64, arm64]
    acquireByHash: true

Known, deliberate limitation: mirrors[].components cannot be changed after a mirror has been created — aptly's API has no endpoint for that. Changing it requires deleting and recreating the mirror. Everything else in a mirror definition (filter, architectures, download flags, …), and all of a local repo's metadata, is synchronized on every run.

Idempotency: GET-before-write, never POST-and-swallow-40x (a swallowed 400 would also skip real changes). reconcile.failOnError (Helm) resp. APTLY_FAIL_ON_ERROR (Compose/env) defaults to false — an unreachable mirror must not make helm upgrade/docker compose up fail.

The prefix-escaping pitfall: aptly's API expects the root prefix in the URL as the literal . (not an empty path) — and curl normalizes away /./ segments client-side unless you set --path-as-is (every script here already does). Coming back the other way, GET /api/publish also returns the root prefix as the literal "." (empirically verified, not "") — so when checking "does this publish target already exist", you need to compare against ".", not against the empty string from values.yaml/state.yaml. Nested prefixes (debian/stable), on the other hand, are returned with real slashes, not underscore-escaped — the escaped form (/_) is only a quirk of URL path construction, not of the stored/listed values. Both are already handled correctly in rootfs/usr/local/bin/aptly-reconcile (api_prefix() and the prefix_for_compare special case) — worth knowing if you write your own scripts against the same API.

GPG

Default: the gpg provider (a real gnupg binary, included in the image). Key source in production is always a Secret (aptly.gpg.signingKey.existingSecret in the chart, compose/config/gpg/private.asc in Compose) — never inline in values.yaml. The key ID is auto-detected (gpg --list-secret-keys --with-colons); the public key is served under /signing-key.asc so clients can reference it directly via signed-by= (a copy-paste snippet appears in the Helm NOTES resp. compose/README).