Add GitHub Packages (GHCR) support, downloading images as OCI layouts - #232
Open
yarikoptic wants to merge 3 commits into
Open
Add GitHub Packages (GHCR) support, downloading images as OCI layouts#232yarikoptic wants to merge 3 commits into
yarikoptic wants to merge 3 commits into
Conversation
Backs up container packages from the GitHub Container Registry. Each package version is saved as an OCI image layout directory that podman and skopeo read directly. Registry access goes through oras-py, the ORAS project's Python SDK, which handles image references and the WWW-Authenticate token exchange that GHCR requires -- a GitHub token presented directly as a bearer token is rejected, and there is no anonymous fallback, so getting this wrong yields no image at all. Walking manifests and writing the layout is done here, since oras-py's own layout puller rejects the Docker media types that GHCR serves for anything pushed by buildx. Manifests are stored exactly as served, so archived digests are the upstream digests. Blobs are streamed to a temporary file, checked against their digest, and only then moved into place, and index.json is written last, so an interrupted download is never mistaken for a complete image. Images are fetched by digest rather than by tag. The GitHub API only lists packages per owner, so packages belonging to other repositories are filtered out unless `owner_wide` is set, and untagged versions -- mostly buildx intermediates and attestations -- are skipped unless `untagged` is set. Tests run against an in-process registry serving images built from /bin/busybox, so they need neither network nor credentials; where podman is installed they check that it can run the layouts that come out. Closes #217
flake8-bugbear/-builtins/-unused-arguments findings from CI, plus the test images only need a real shell for the podman tests -- everything else just needs bytes, so the rest of the suite now runs on a machine without a static busybox instead of being skipped wholesale.
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## master #232 +/- ##
==========================================
+ Coverage 60.49% 66.00% +5.51%
==========================================
Files 10 11 +1
Lines 1420 1715 +295
Branches 222 263 +41
==========================================
+ Hits 859 1132 +273
- Misses 495 511 +16
- Partials 66 72 +6 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
An alternative to #222 for #217. Opened separately rather than pushed onto that branch so the two can be compared; close whichever loses.
Why not #222
Reviewing #222 first, the container-download path added in its last commit has never run successfully — the PR has zero check runs, and its integration test fails immediately. Three defects each independently prevent any image from being downloaded:
Authorization: Bearer <raw GitHub PAT>toghcr.io, reusing theapi.github.comclient. GHCR does not accept a raw PAT as a bearer token, and an invalid header gets a403 DENIEDrather than an anonymous fallback, even for public images:log.debug, so a run reports "N package versions saved" while having written only a stub JSON file.GET /v2/<img>/blobs/<digest>; manifests are not blobs. Against a real dandi image that returns404 BLOB_UNKNOWN. Every dandi package is multi-arch.sort_keys=Truebefore hashing, so the archived digest is not the upstream digest and the copy can never be verified against, or restored under, its original reference.Beyond that: a
403from/orgs/{owner}/packages(a token withoutread:packages, which includes the ActionsGITHUB_TOKEN) is not caught and aborts the wholefetch, logs and artifacts included; the package listing is org-wide with no repo scoping; and the integration testos.chdirs into a deleted temporary directory, which breaks 7 unrelated tests intest_state.py.Approach
I looked for a Python library rather than shelling out to
skopeo.oras(oras-py)requests+jsonschema). Handles image references, credentials, and theWWW-Authenticatetoken exchange.python-dxfwww-authenticatedependency fails to build on current setuptools. No layout support, no manifest-list recursion.docker/podman-pycontainerregistry(Google)orasships an OCI layout puller (oras.layout.NewLayoutFromRegistry) that does almost exactly what is wanted, and does the things #222 got wrong correctly — verbatim manifest bytes,Docker-Content-Digest, recursive index walk. It cannot be used as-is: it dispatches only on the OCI media types, and GHCR serves the Docker spellings for anything pushed bydocker buildx, which is both of our targets:So this PR uses
orasfor the part that is genuinely subtle and well-tested — the registry client and the auth dance — and keeps the manifest walk in tinuous (src/tinuous/ghcr.py, ~130 lines of logic). That also lets the walk own things a backup tool wants and the library does not do: digest verification, atomic blob writes, and a completeness marker.Both gaps are being taken upstream so this module can shrink later: the media-type dispatch (unreported; also affects
push_to_registry, where a partial fix would silently upload manifests as plain blobs), and blob/manifest digest verification (already filed upstream as oras-py#247).What it does
paths.packagesgets a template; each package version is saved as an OCI image layout:Correctness properties, each covered by a test:
.tmpfile, are hashed, and are moved into place only on a match; a failure removes the partial file rather than leaving something a later run mistakes for complete.index.jsonis written last, so an interrupted download is not a valid layout and is redone.{tag}-keyed path keeps up with a moved tag instead of freezing on the first image it saw.Failure modes are loud: a token without
read:packagesgetsRuntimeError: ... a token with the 'read:packages' scope is needed, not a silent empty result.Repo scoping
GitHub only lists packages per owner.
https://github.com/orgs/dandi/packageshas ~33 container packages and all of them belong todandi/example-notebooks, so with #222's org-wide listing a backup of any other dandi repo would try to pull all of them. Here the default is to fetch only packages whoserepository.full_namematches the configured repo;owner_wide: trueopts into the old behaviour.Volume
dandi/example-notebooks/001550-paganlabis 908 MB across its two platforms and carries 20 tags. Versions of a package share most of their layers; inside a DataLad dataset git-annex stores each layer once regardless of how many versions reference it. Documented in the README.Testing
test/fake_registry.pyis an in-process registry serving images built from/bin/busybox— single-platform, multi-platform, and a nested name like dandi's, in both Docker and OCI media types, optionally behind a token challenge. Tests need no network and no credentials. Where podman and a static busybox are both present, three tests runpodman run oci:<layout>and assert on the container's output; where they are not, only those three skip.Against the real registry, verified from a sandbox whose egress policy blocks GHCR's blob CDN (
pkg-containers.githubusercontent.com—skopeo copyfails there identically), so layer bytes could not be transferred; blob downloads were replaced by an unredirected HEAD, which still proves each blob is reachable and the size the manifest claims. Everything else is the real code path:ghcr.io/con/tinuous-inception:latestmanifest.v2ghcr.io/dandi/example-notebooks/001550-paganlab:latestmanifest.list.v2ghcr.io/dandi/example-notebooks/000409-ibl:hash-064621cba038-arm64manifest.v2All three produced a complete layout whose
index.jsondigest matched the digest the registry reported.Two things could not be exercised here and want a real run before merge: the
/orgs/{owner}/packageslisting (my token lacksread:packages, so that path is covered only by unit tests against mocked responses), and a pull of a private package with a real token.CI
Green on
cc49949— GitHub Actions (lint, typing, codespell, CPython 3.10–3.14, pypy-3.11,test (base),test (datalad)), CircleCI, Codecov, and both AppVeyor builds. Coverage goes up: 60.49% → 66.00% (+5.51%).For the record, since it was red for a while: on the pre-merge head this PR had four failing checks, all of which #233 resolved rather than anything in this branch.
test (base)/test (datalad)were dying onKeyError: 'next_page_token'insrc/tinuous/circleci.py, code this PR does not touch; the two pypy jobs could not buildpydantic-coreon PyPy < 3.11 and were red onmastertoo. AppVeyor was also red and I could not diagnose it —ci.appveyor.comis unreachable from where I was working — and it went green once #233 dropped Python 3.9 from the AppVeyor matrix, which is consistent with the suspicion that it was the 3.9 job failing to resolve the neworas/jsonschemadependency chain (jsonschema >= 4.26needs 3.10,rpds-py >= 2026needs 3.11). That remains inferred, not proven — I never saw the log.