fix(image-updates): normalize docker.io host aliases to the registry API host (#1706)

* fix(image-updates): normalize docker.io host aliases to the registry API host

parseImageRef kept a literal `docker.io` or `index.docker.io` host when the
user wrote an explicit registry prefix, so every downstream request hit the
marketing domain instead of registry-1.docker.io and failed with an
unhandled 3xx. Normalize both aliases before the library/ auto-prefix check
so explicit and implicit Docker Hub refs resolve identically.

* test(image-updates): pin docker.io alias parity and drop redundant coverage

Add the index.docker.io namespace-omitted parseImageRef case and a
buildRollbackTarget assertion for an explicit docker.io/ ref, and drop
the compareLocalToRemoteTag traefik-shape test that only duplicated
existing attestation-manifest coverage. Also clarify the canonicalRegistry
doc comment now that it and parseImageRef normalize to different forms.

* docs(image-updates): correct canonicalRegistry comment's call-path example

* test(image-updates): collapse docker.io alias cases into a single it.each
This commit is contained in:
Anso
2026-07-26 01:56:12 -04:00
committed by GitHub
parent 9859ce60b8
commit bb7c76ba46
3 changed files with 49 additions and 2 deletions
@@ -115,6 +115,34 @@ describe('repoDigestMatchesRef', () => {
});
});
describe('parseImageRef', () => {
// docker.io / index.docker.io / registry-1.docker.io are the same registry, but only
// the literal 'registry-1.docker.io' is recognized elsewhere (getAuthToken, the
// library/ auto-prefix in parseImageRef). An unnormalized 'docker.io' or 'index.docker.io'
// leaks through into request URLs and hits the marketing domain instead of the registry API.
// Each alias is listed with and without an explicit library/ namespace to pin both paths.
it.each([
'docker.io/library/traefik:latest',
'docker.io/traefik:latest',
'index.docker.io/library/traefik:latest',
'index.docker.io/traefik:latest',
])('normalizes %s to the registry API host and the library/ namespace', (ref) => {
expect(parseImageRef(ref)).toEqual({
registry: 'registry-1.docker.io',
repo: 'library/traefik',
tag: 'latest',
});
});
it('leaves a bare official image name unchanged (regression guard)', () => {
expect(parseImageRef('traefik:latest')).toEqual({
registry: 'registry-1.docker.io',
repo: 'library/traefik',
tag: 'latest',
});
});
});
describe('getRemoteDigest HEAD-first lookup', () => {
beforeEach(() => {
calls.length = 0;
@@ -432,6 +432,15 @@ describe('buildSummary', () => {
expect(buildSummary('stacky', images).rollback_target).toBe('postgres:16');
});
it('computes rollback target for an explicit docker.io/ ref the same as the bare form', () => {
// docker.io is a Docker Hub alias normalized by parseImageRef; the rollback target
// must render the same shortened form as the bare-name case above, not the literal host.
const images = [
baseImage({ service: 'db', image: 'docker.io/library/postgres:16', has_update: true, semver_bump: 'patch', next_tag: '16', current_tag: '16' }),
];
expect(buildSummary('stacky', images).rollback_target).toBe('postgres:16');
});
it('computes rollback target for registry with port', () => {
const images = [
baseImage({
+12 -2
View File
@@ -35,7 +35,12 @@ export function parseImageRef(imageRef: string): ParsedRef | null {
if (slashIdx !== -1) {
const firstPart = imageRef.slice(0, slashIdx);
if (firstPart.includes('.') || firstPart.includes(':') || firstPart === 'localhost') {
registry = firstPart;
// docker.io / index.docker.io are Docker Hub aliases; normalize them to the
// actual registry API host so requests never hit the marketing domain (which
// redirects instead of serving /v2/) and the library/ auto-prefix below still applies.
registry = (firstPart === 'docker.io' || firstPart === 'index.docker.io')
? 'registry-1.docker.io'
: firstPart;
rest = imageRef.slice(slashIdx + 1);
}
}
@@ -214,7 +219,12 @@ const MANIFEST_ACCEPT = [
'application/vnd.oci.image.manifest.v1+json',
].join(', ');
/** docker.io has three hostnames that all address the same registry. */
/**
* docker.io has three hostnames that all address the same registry. Folds two
* already-parsed ParsedRef.registry values down to a shared form for equality/cache-key
* comparisons; not the canonical form parseImageRef assigns (which normalizes to
* 'registry-1.docker.io').
*/
function canonicalRegistry(host: string): string {
if (host === 'docker.io' || host === 'index.docker.io' || host === 'registry-1.docker.io') {
return 'docker.io';