Skip to content

build(docker): allow third-party addon repos to be pinned by commit sha - #507

Open
reichie020212 wants to merge 5 commits into
19.0from
feat/download-module-commit-sha-pin
Open

build(docker): allow third-party addon repos to be pinned by commit sha#507
reichie020212 wants to merge 5 commits into
19.0from
feat/download-module-commit-sha-pin

Conversation

@reichie020212

@reichie020212 reichie020212 commented Sep 7, 2026

Copy link
Copy Markdown
Member

Problem

download_module in docker/Dockerfile can only fetch a branch head. The URL is
hardcoded to the branch namespace:

curl -sSL -o "$tarball" \
    "https://github.com/${repo}/archive/refs/heads/${branch}.tar.gz";

GitHub serves a commit sha from /archive/<sha>.tar.gz and 404s on
/archive/refs/heads/<sha>.tar.gz, so there is no way to pass a sha through this
function:

200  https://github.com/OCA/server-ux/archive/refs/heads/19.0.tar.gz
200  https://github.com/OCA/server-ux/archive/8e5120600987969156c2a59c1ad86bec37318966.tar.gz
404  https://github.com/OCA/server-ux/archive/refs/heads/8e5120600987969156c2a59c1ad86bec37318966.tar.gz

The practical consequence: the six third-party addon repositories are an unpinned
dependency of a pinned image
. Two builds of the same OpenSPP2 commit, a week apart, can
bake in different OCA/server-ux, OCA/server-tools, OCA/rest-framework, … code — and
a deployment that pins OPENSPP2_COMMIT still has no way to say which addon commits it
was tested against. Reproducing a build, or bisecting a regression that came from an OCA
change rather than ours, is not currently possible.

Reported by @reichie020212 while pinning an OpenSPP2 build for the DSWD 4Ps deployment:

I am checking the Dockerfile […] and analyzed the function download_module — based on
the current code, we can only pin OCA resources using a branch unless download_module
is modified to accept commit sha.

Change

  • download_module's third parameter is now a ref: a branch or a commit sha
    (short or full). 7-or-more hex digits selects /archive/<ref>.tar.gz; anything else
    keeps today's /archive/refs/heads/<ref>.tar.gz. A tag name is not accepted — it is
    non-hex, so it takes the refs/heads/ route and 404s; pass the commit sha the tag
    points at. (This paragraph and both doc copies said "a branch, a tag or a commit sha"
    until review round 1; the code never routed tags, and the prose has been corrected
    rather than the routing widened — see finding 1 below.)

  • One ARG <REPO>_REF=19.0 per downloaded repository, so a deployment can pin without
    editing the Dockerfile:

    docker build --build-arg OCA_SERVER_UX_REF=<sha> -f docker/Dockerfile -t openspp .

    A hex-only branch name of 7+ characters (deadbee) would be read as a sha — harmless,
    because GitHub's bare /archive/<rev>.tar.gz resolves branch names as well
    (/archive/19.0.tar.gz → 200); the branch form is kept for non-shas only because it is
    unambiguous between a branch and a like-named tag.

  • docker/README.md documents the arguments under Build. curl gained -f so a bad
    ref fails at the download with its 404 instead of later in tar with
    not in gzip format over a cached 14-byte error body.

  • No repository's default ref is changed. Every *_REF defaults to 19.0, which is
    what the call sites resolve today. Which commits to pin is a deployment decision, not
    one this PR makes.

The ARGs sit immediately above the RUN that consumes them, for two reasons: a global
ARG declared before FROM is not in scope inside a stage (under the RUN's set -eu
that is a hard build failure, not an empty string), and declaring them at the top of the
builder stage would invalidate the apt/uv layers whenever a ref changes.

Backwards compatibility

This is the top requirement, so it is asserted rather than argued: the pre-change and
post-change functions were both extracted from the Dockerfile and replayed over all six
production call sites with a stubbed curl recording (tarball, url). The recordings are
byte-identical:

$ diff -u /tmp/rec_old.txt /tmp/rec_new.txt   # no output
/tmp/downloads/x-server-ux-19.0.tar.gz|https://github.com/OCA/server-ux/archive/refs/heads/19.0.tar.gz
/tmp/downloads/x-server-tools-19.0.tar.gz|https://github.com/OCA/server-tools/archive/refs/heads/19.0.tar.gz
/tmp/downloads/x-odoo-job-worker-19.0.tar.gz|https://github.com/OpenSPP/odoo-job-worker/archive/refs/heads/19.0.tar.gz
/tmp/downloads/x-server-backend-19.0.tar.gz|https://github.com/OCA/server-backend/archive/refs/heads/19.0.tar.gz
/tmp/downloads/x-rest-framework-19.0.tar.gz|https://github.com/OCA/rest-framework/archive/refs/heads/19.0.tar.gz
/tmp/downloads/x-muk-it-19.0.tar.gz|https://github.com/muk-it/odoo-modules/archive/refs/heads/19.0.tar.gz

Neither the URLs nor the /tmp/downloads cache keys move, so an existing build cache is
still valid and a build that passes no new argument downloads exactly what it downloads
today.

Verification

The function body was extracted verbatim from the Dockerfile (comment-only lines
stripped the way Docker's parser strips them) and executed under /bin/dash — the real
/bin/sh of the python:3.13-slim-bookworm base — in a throwaway container, against the
live GitHub API:

Check Result
default (no 3rd arg) resolves the pre-change URL …/archive/refs/heads/19.0.tar.gz, 15 addons extracted
explicit branch 19.0 same URL, tree populated
full 40-char sha …/archive/f2d9a5bc….tar.gz, 14 addons extracted
the pin actually pins sha tree differs from branch head in 30 paths
short 7-char sha works; tree identical to the full-sha tree
--strip-components=1 on a sha tarball correct — its root is server-ux-<full sha>/ even for a short sha
cache hit on a repeated ref no second download
cache keys distinct per ref 4 distinct tarballs, all gzip -t clean
ref classification 19.0, 18.0, main, v19.0.1.0.0, abc → branch; deadbee, full sha, upper-case sha → sha
old vs new on all 6 call sites identical (above)
ARG scope + override a stage-scoped ARG reaches the RUN (default 19.0, and an overridden sha); a global ARG referenced in-stage aborts the build under set -eu

Two things I did not verify, stated plainly:

  • No full docker build was run — superseded. CI's build job now builds the
    image end-to-end at head: layer #18 [builder 9/10] runs (not CACHED) with
    curl -fsSL and the six default 19.0 refs, and the job is green
    (run 34310621689).
    The individual exercises below still stand for the sha/branch paths, which a
    default build does not reach.
  • docker buildx build --check --target builder reports no warnings, but a negative
    control showed it does not catch an out-of-scope ARG, so it is evidence that the
    file parses, not that the scoping is right. The scoping was verified with a separate
    minimal build instead.

Known limitation (pre-existing, unchanged)

A ref containing / (e.g. feature/x) breaks the download cache path, because the ref is
interpolated into the tarball filename /tmp/downloads/${dest}-${ref}.tar.gz. This is
true before and after this change — verified against both versions of the function — and
no call site uses such a ref, so it is left alone rather than fixed in passing.

ODOO_SOURCE/ODOO_VERSION (the Odoo/OCB tarball a few lines above) is still
branch-only; extending the same treatment there is deliberately out of scope.

Deploy branch

feat/download-module-commit-sha-pin-on-64e6b31c carries the same commit cherry-picked
onto 64e6b31c (the commit a downstream deployment is currently pinned to), so that
deployment can consume this without taking the 111 commits 19.0 has since gained. This
PR is cut from 19.0 to keep the review clean; docker/Dockerfile and docker/README.md
are identical at both bases, so the cherry-pick was conflict-free.

Review round 1 (@gonzalesedwin1123, ed7e236f)

# Finding Resolution
1 (blocking) Tags documented but not routed Prose narrowed to "a branch or a commit sha" in the Dockerfile comment, docker/README.md and this body. Confirmed live: odoo-job-worker tag 2026.08 → 404 on /archive/refs/heads/, 200 on /archive/refs/tags/. Routing was left alone so no default resolution path moves; a tag is pinnable today as its sha, which is the reproducible form anyway.
2 curl -fsSL Taken. Replayed under /bin/dash with a bad ref: before → gzip: stdin: not in gzip format, RUN exit 2, 14-byte tarball left in the cache; after → curl: (22) ... 404, RUN exit 22, nothing cached.
3 README example line was not a pin OCA_SERVER_TOOLS_REF now carries a real sha (028b450b…, checked 200).
Observation (buildkit cache never refreshes a branch tarball) Out of scope, agreed — filed separately.

Defaults re-verified after the round: the six call sites replayed old-vs-new under /bin/dash record byte-identical (tarball, url) pairs, so build caches stay valid and the only textual change to the function is the -f flag.

download_module could only fetch a branch head: it hardcoded the
/archive/refs/heads/<ref>.tar.gz URL, which 404s for a commit sha. The
addon repositories were therefore an unpinned dependency, and two builds
of the same OpenSPP commit could pick up different OCA code.

Give the function a ref that may be a branch, a tag or a commit sha, and
expose one ARG per downloaded repo so a deployment can pin without
editing the Dockerfile:

    docker build --build-arg OCA_SERVER_UX_REF=<sha> ...

A sha carries no ref namespace, so it is served from /archive/<sha>.tar.gz
instead; 7 or more hex digits (short or full sha) selects that form and
anything else keeps today's branch URL. Every default is 19.0, so a build
that passes no new argument resolves exactly the same six URLs and
tarball cache paths as before. No repository's default ref is changed
here — which commits to pin is a deployment decision.

The ARGs sit immediately above the RUN that consumes them so that
changing a ref only invalidates the download layer, and because a global
ARG declared before FROM is not in scope inside the stage.

Signed-off-by: Red <redick@newlogic.com>
@codecov

codecov Bot commented Sep 7, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 76.88%. Comparing base (c4329e2) to head (b896806).

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             19.0     #507      +/-   ##
==========================================
- Coverage   76.92%   76.88%   -0.05%     
==========================================
  Files         735      703      -32     
  Lines       47935    45739    -2196     
==========================================
- Hits        36874    35166    -1708     
+ Misses      11061    10573     -488     
Flag Coverage Δ
spp_area ?
spp_area_hdx ?
spp_audit ?
spp_base_common 91.07% <ø> (ø)
spp_programs 67.58% <ø> (ø)
spp_registry 88.94% <ø> (ø)
spp_security 69.56% <ø> (ø)
spp_user_roles ?

Flags with carried forward coverage won't be shown. Click here to find out more.
see 32 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@reichie020212

Copy link
Copy Markdown
Member Author

About the three red test checks — they are not caused by this change, and they make its case

build and pre-commit pass. test (spp_programs), test (spp_base_common) and
test-summary fail with:

odoo.tools.convert.ParseError: while parsing /mnt/extra-addons/openspp/spp_user_roles/views/user.xml:17
Element '<xpath expr="//field[@name='role_ids']">' cannot be located in parent view

Why it happens. spp_user_roles inherits base_user_role.view_res_users_tree_inherit
and xpaths //field[@name='role_ids']. OCA renamed that field role_ids
user_role_ids in OCA/server-backend on 2026-05-18
(c7e2d1a66fee52c0f942ce9b3aebe51760c59a18, "[19.0][FIX] base_user_role: Cannot see User
Roles"), so the field the xpath looks for has not existed on the 19.0 branch head for
almost four months.

Why 19.0 is nevertheless green. In the last green 19.0 run (df808efa,
run 33846557584) the addon
download layer was reused from the GHA layer cache:

#11 [builder  9/10] RUN --mount=type=cache,target=/tmp/downloads,... download_module() { ...
#11 CACHED

That cached layer holds a pre-rename snapshot of base_user_role. This PR edits that very
RUN instruction, which correctly invalidates the layer, so the build downloads today's
OCA heads for the first time in months — and the pre-existing incompatibility surfaces.
Any PR touching that instruction (or any layer above it) would hit the same wall, and
so would the first cache eviction.

What the same logs prove about backwards compatibility. In this PR's build the layer
ran with no build args and traced:

#18 0.065 + download_module OCA/server-ux server-ux 19.0
#18 0.065 + local url=https://github.com/OCA/server-ux/archive/refs/heads/19.0.tar.gz

— i.e. the pre-change URL, resolved by CI itself, for all six repositories.

Two ways forward, both yours to pick. Fix spp_user_roles for user_role_ids (the
real fix — the tests are telling the truth), or, once this PR is in, pin the addon repo
deliberately, e.g. --build-arg OCA_SERVER_BACKEND_REF=4e3a012a7b6f2516ebde8fdaeada6ae706780c7d (the commit before the
rename) while the module is updated. This PR intentionally changes no default, so it
neither fixes nor worsens the incompatibility — it just stops the layer cache from being
the thing that decides which OCA code you ship.

@gonzalesedwin1123 gonzalesedwin1123 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at ed7e236 (CI green after the branch update picked up the spp_user_roles fix from #508).

Verified

  • The diff is exactly docker/Dockerfile + docker/README.md; the merge commit adds nothing else.
  • Replayed the ref classifier under /bin/dash for 13 refs: 19.0, 18.0, main, abc, 123456refs/heads/…; deadbee, DEADBEE, 1234567 and a full sha → bare /archive/<ref>. Cache keys for the six default call sites are unchanged.
  • Live GitHub: /archive/19.0 and /archive/refs/heads/19.0 → 200; /archive/8e51206 (short sha) → 200 and the tarball root is server-ux-<full sha>/, so --strip-components=1 is right; /archive/deadbee → 404.
  • Nothing else in the repo references download_module or the old URL; ci.yml, ci-full.yml and security.yml build with defaults, so no workflow changes are needed.

Must fix before merge

1. Tags are documented but not routed. The README and the Dockerfile comment say a *_REF accepts "a branch, a tag or a commit sha". A tag is non-hex, so it takes the refs/heads/ route and 404s. Checked live on OpenSPP/odoo-job-worker tag 2026.08:

404  /archive/refs/heads/2026.08.tar.gz
200  /archive/refs/tags/2026.08.tar.gz
200  /archive/2026.08.tar.gz

Either drop "tag" from both places, or send every non-branch ref through the bare /archive/<ref>.tar.gz form, which resolves branches, tags and shas alike. A deployer following the README as written would hit the cryptic tar failure below.

Suggestions (non-blocking)

2. curl -fsSL. Without -f, a 404 stores a 14-byte "Not Found" body and the build dies later in tar with not in gzip format. It is not permanent (the gzip -t check re-downloads next build) but the message hides the cause. Pre-existing, but sha pinning makes a mistyped ref more likely, so worth adding here.

3. README example. Under "pin the addons to exact commits", OCA_SERVER_TOOLS_REF=19.0 is the default branch, not a pin. Either drop that line or use a sha.

Observation, out of scope

With a persistent buildkit cache mount, a branch tarball such as server-ux-19.0.tar.gz is reused as long as it is valid gzip, so a long-lived builder never refreshes branch heads. That is the mirror image of the drift this PR fixes; sha pinning sidesteps it. Worth a separate issue.

Happy to approve as soon as (1) is addressed.

Review finding 1 on #507. The Dockerfile comment and docker/README.md both
advertised "a branch, a tag or a commit sha", but a tag name is non-hex and so
takes the refs/heads route, which 404s:

  404  /archive/refs/heads/2026.08.tar.gz
  200  /archive/refs/tags/2026.08.tar.gz
  (OpenSPP/odoo-job-worker, checked live)

A deployer following the README as written got a curl 404 body and a later
"not in gzip format" failure from tar. Narrow the prose to what the code does
and point at the tag's commit sha, which is the reproducible pin anyway.

No behaviour change.

Signed-off-by: Red <redick@newlogic.com>
Review suggestion 2 on #507. Without -f, a 404 is written to the cache as a
14-byte "Not Found" body and the build dies further down in tar with
"not in gzip format", which hides the cause. Replayed under /bin/dash in
python:3.13-slim-bookworm with a deliberately bad ref:

  before:  gzip: stdin: not in gzip format / tar: Child returned status 1
           RUN exit 2, and a 14-byte tarball left in the /tmp/downloads cache
  after:   curl: (22) The requested URL returned error: 404
           RUN exit 22, and nothing cached

Pre-existing, but sha pinning makes a mistyped ref likelier, so it is worth
having here. Defaults are untouched: the six call sites replayed old-vs-new
under dash record byte-identical (tarball, url) pairs, so an existing build
cache stays valid.

Signed-off-by: Red <redick@newlogic.com>
Review suggestion 3 on #507. Under "pin the addons to exact commits" the
second line read OCA_SERVER_TOOLS_REF=19.0, which is the default branch and so
demonstrates the opposite of the sentence above it. Use a real OCA/server-tools
commit sha (028b450b, 19.0 head at the time of writing; /archive/<sha>.tar.gz
checked 200).

Signed-off-by: Red <redick@newlogic.com>
@reichie020212

Copy link
Copy Markdown
Member Author

Thanks — all three addressed at b8968061, one commit each. CI is green (18/18, same set that was green at ed7e236f).

1. Tags documented but not routed — fixed, by narrowing the prose

You are right, and I took the first of your two options: the routing is unchanged and the claim is now what the code does. Reproduced your check independently before touching anything:

404  https://github.com/OpenSPP/odoo-job-worker/archive/refs/heads/2026.08.tar.gz
200  https://github.com/OpenSPP/odoo-job-worker/archive/refs/tags/2026.08.tar.gz

19659fc7 narrows both copies to "a branch or a commit sha" and points a would-be tag pinner at the tag's commit sha. The PR body said the same wrong thing and has been corrected too, with a note saying it was wrong rather than a silent edit.

Why not the second option (route everything through the bare /archive/<ref>.tar.gz): it works, but it moves the resolution path for the six defaults, and "no default's URL or cache key moves" is the one property this PR leans on for backwards compatibility. It also makes a branch and a like-named tag ambiguous. A tag is mutable anyway, so pinning one is a weaker guarantee than pinning the sha it points at — which is the thing this PR exists to enable. If you would rather have real tag support, say so and I will add a refs/tags/ route as a follow-up — I did not want to widen the PR mid-review to add a capability nobody has asked for yet.

2. curl -fsSL — taken (13c03094)

Agreed, and it is worth more than the message improvement. Replayed the extracted function under /bin/dash in python:3.13-slim-bookworm with a deliberately bad ref:

before after
where it fails tar: gzip: stdin: not in gzip format curl: (22) The requested URL returned error: 404
RUN exit 2 22
left in the /tmp/downloads cache a 14-byte "Not Found" tarball nothing

So -f also stops the poisoned entry being written in the first place, rather than relying on the next build's gzip -t to evict it.

3. README example was not a pin — fixed (b8968061)

OCA_SERVER_TOOLS_REF now carries a real sha, 028b450b06ebd70424534cbd89f37ed134f7200d (/archive/<sha>.tar.gz → 200).

Observation (buildkit cache never refreshes a branch tarball) — filed as #510

Out of scope here, agreed, and I think it is the same mechanism as the base_user_role breakage one layer up. Written up with the three options I can see; it needs a decision rather than a patch.

Re-verification after the round

  • Defaults still byte-identical. Extracted the pre- and post-change RUN bodies (comment-only lines stripped the way Docker's parser strips them — confirmed against CI's own echo of the layer) and replayed both under /bin/dash over all six call sites with a stubbed curl recording (tarball, url). diff is empty. The only textual difference in the function is the -f.
  • A real image build now exists, which the original body could not claim: CI's build job re-ran the download layer (#18 [builder 9/10], not CACHED) with curl -fsSL and the six 19.0 defaults, green. I have struck the "no full docker build was run" caveat from the body.
  • No test suites are in scope — the diff is still exactly docker/Dockerfile + docker/README.md.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants