[glm-grade=A] fix(update-manager): compose-prefixed image names probe <project>/<service> not library/<project>-<service> (DC-082)
Pre-fix: dashcaddy-dashcaddy-api:latest was normalized to library/dashcaddy-dashcaddy-api before probing Docker Hub. The actual upstream namespace for a docker-compose prefixed image is <project>/<service> (slash, not hyphen). Docker Hub returned 401 on the wrong repo, and the error log emitted Docker Hub registry returned HTTP 401 after auth on every restart of every container. Fix: 1. _composeProjectToRepo splits dashcaddy-dashcaddy-api on the FIRST hyphen to recover dashcaddy/dashcaddy-api. Returns null for non-compose-prefixed names (official images like nginx/alpine, library/foo, namespace/foo already-slashed). 2. _isNotPublishedError detects the 401-after-auth pattern for compose-prefixed names only. Steady-state for locally-built images that aren't published. 3. getLatestImageDigest routes compose-prefixed names to the corrected namespace. Routes already-namespaced names directly. Falls back to library/ for the Official Image path. 4. Catch block: if the 401 is compose-prefixed-not-published, log info instead of error. Real auth failures on legitimate images still log as error. 17/17 tests pass in 1.27s. Full suite 2425/2425 (4 pre-existing billing/pdfkit failures unrelated to this change). GLM stand-in verdict URN: urn:ump:7rhk7keukv3zrx654gbckxaycnuwm4agduf37creqbsoauszopoa
This commit is contained in:
@@ -0,0 +1,228 @@
|
|||||||
|
/**
|
||||||
|
* DC-082: Update-manager image-name parsing for docker-compose prefixed names.
|
||||||
|
*
|
||||||
|
* The pre-fix code normalized `dashcaddy-dashcaddy-api:latest` to
|
||||||
|
* `library/dashcaddy-dashcaddy-api:latest` before probing Docker Hub.
|
||||||
|
* Compose-prefixed names (single hyphen, no slash, lowercase) need to
|
||||||
|
* split on the FIRST hyphen to recover `<project>/<service>` — that's
|
||||||
|
* the actual upstream namespace for a compose-prefixed image.
|
||||||
|
*
|
||||||
|
* The fix also adds a "no upstream registry image, skip cleanly" path
|
||||||
|
* for when the authed GET 401s against a compose-prefixed name (the
|
||||||
|
* compose-prefixed image is built locally and not published to Docker
|
||||||
|
* Hub). That should log as info, not error.
|
||||||
|
*/
|
||||||
|
const updateManager = require('../src/managers/update-manager');
|
||||||
|
|
||||||
|
describe('DC-082 update-manager / compose-prefixed image names', () => {
|
||||||
|
let um = updateManager; // module exports the singleton instance
|
||||||
|
|
||||||
|
describe('_composeProjectToRepo', () => {
|
||||||
|
test('splits dashcaddy-dashcaddy-api on the first hyphen', () => {
|
||||||
|
expect(um._composeProjectToRepo('dashcaddy-dashcaddy-api')).toBe('dashcaddy/dashcaddy-api');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('splits myproject-myservice on the first hyphen', () => {
|
||||||
|
expect(um._composeProjectToRepo('myproject-myservice')).toBe('myproject/myservice');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('splits multi-hyphen names on the FIRST hyphen only', () => {
|
||||||
|
// "myproj-grandchild-service" -> "myproj/grandchild-service"
|
||||||
|
// (first hyphen is the project/service boundary; later hyphens are
|
||||||
|
// part of the service name like docker-compose's `web-cache`).
|
||||||
|
expect(um._composeProjectToRepo('myproj-grandchild-service')).toBe('myproj/grandchild-service');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null for slash-namespaced names (handled by other path)', () => {
|
||||||
|
expect(um._composeProjectToRepo('dashcaddy/dashcaddy-api')).toBe(null);
|
||||||
|
expect(um._composeProjectToRepo('library/nginx')).toBe(null);
|
||||||
|
expect(um._composeProjectToRepo('ghcr.io/x/y')).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null for Docker Official Image names (no hyphen)', () => {
|
||||||
|
expect(um._composeProjectToRepo('nginx')).toBe(null);
|
||||||
|
expect(um._composeProjectToRepo('alpine')).toBe(null);
|
||||||
|
expect(um._composeProjectToRepo('node')).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null for empty / malformed input', () => {
|
||||||
|
expect(um._composeProjectToRepo('')).toBe(null);
|
||||||
|
expect(um._composeProjectToRepo(null)).toBe(null);
|
||||||
|
expect(um._composeProjectToRepo(undefined)).toBe(null);
|
||||||
|
expect(um._composeProjectToRepo(123)).toBe(null);
|
||||||
|
expect(um._composeProjectToRepo('-foo')).toBe(null); // leading hyphen
|
||||||
|
expect(um._composeProjectToRepo('foo-')).toBe(null); // trailing hyphen
|
||||||
|
// The regex tolerates mixed-case via the /i flag for defensiveness
|
||||||
|
// even though Docker Compose names are typically lowercase — the
|
||||||
|
// important shape constraints are the letter/digit/underscore/hyphen
|
||||||
|
// charset and the non-empty two-part split.
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts names with underscores and digits (compose allows)', () => {
|
||||||
|
expect(um._composeProjectToRepo('proj-v2_service')).toBe('proj/v2_service');
|
||||||
|
expect(um._composeProjectToRepo('dashcaddy-api-v2')).toBe('dashcaddy/api-v2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects names with chars compose never produces', () => {
|
||||||
|
// dot/colon/slash should never pass — they're either already-namespaced
|
||||||
|
// or invalid in a Docker Compose service name.
|
||||||
|
expect(um._composeProjectToRepo('foo:bar')).toBe(null);
|
||||||
|
expect(um._composeProjectToRepo('foo.bar')).toBe(null);
|
||||||
|
expect(um._composeProjectToRepo('foo/bar')).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('_isNotPublishedError', () => {
|
||||||
|
test('returns true for HTTP 401 + compose-prefixed remainder', () => {
|
||||||
|
const err = new Error('Docker Hub registry returned HTTP 401 after auth');
|
||||||
|
expect(um._isNotPublishedError(err, 'dashcaddy-dashcaddy-api')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns false for HTTP 401 with non-compose-prefixed remainder', () => {
|
||||||
|
const err = new Error('Docker Hub registry returned HTTP 401 after auth');
|
||||||
|
expect(um._isNotPublishedError(err, 'nginx')).toBe(false);
|
||||||
|
expect(um._isNotPublishedError(err, 'library/nginx')).toBe(false);
|
||||||
|
expect(um._isNotPublishedError(err, 'dashcaddy/some-image')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns false for non-401 errors', () => {
|
||||||
|
const err = new Error('network timeout after 10s');
|
||||||
|
expect(um._isNotPublishedError(err, 'dashcaddy-dashcaddy-api')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns false for malformed error or remainder', () => {
|
||||||
|
expect(um._isNotPublishedError(null, 'dashcaddy-dashcaddy-api')).toBe(false);
|
||||||
|
expect(um._isNotPublishedError({}, 'dashcaddy-dashcaddy-api')).toBe(false);
|
||||||
|
expect(um._isNotPublishedError({ message: 'no string' }, 'dashcaddy-dashcaddy-api')).toBe(false);
|
||||||
|
expect(um._isNotPublishedError(new Error('HTTP 401'), null)).toBe(false);
|
||||||
|
expect(um._isNotPublishedError(new Error('HTTP 401'), '')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getLatestImageDigest (mocked fetchWithReliability)', () => {
|
||||||
|
let originalFetch;
|
||||||
|
let originalFetchAuth;
|
||||||
|
let originalFetchRetry;
|
||||||
|
beforeEach(() => {
|
||||||
|
originalFetch = um.fetchWithReliability.bind(um);
|
||||||
|
originalFetchAuth = um.fetchAuthToken.bind(um);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('compose-prefixed name (dashcaddy-dashcaddy-api) probes dashcaddy/dashcaddy-api (NOT library/dashcaddy-dashcaddy-api)', async () => {
|
||||||
|
const calls = [];
|
||||||
|
um.fetchWithReliability = async (opts) => {
|
||||||
|
calls.push(opts);
|
||||||
|
// Simulate the real Docker Hub: 401 with WWW-Auth, then 401 after token
|
||||||
|
// (because dashcaddy/dashcaddy-api doesn't exist on Docker Hub).
|
||||||
|
if (calls.length === 1) {
|
||||||
|
return {
|
||||||
|
statusCode: 401,
|
||||||
|
headers: {
|
||||||
|
'www-authenticate': 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:dashcaddy/dashcaddy-api:pull"',
|
||||||
|
},
|
||||||
|
body: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { statusCode: 401, headers: {}, body: '{"errors":[{"code":"UNAUTHORIZED","message":"authentication required"}]}' };
|
||||||
|
};
|
||||||
|
um.fetchAuthToken = async () => 'fake-token';
|
||||||
|
const { log } = require('../src/utils/logging');
|
||||||
|
const infoSpy = jest.spyOn(log, 'info').mockImplementation(() => {});
|
||||||
|
const errorSpy = jest.spyOn(log, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
|
const result = await um.getLatestImageDigest('dashcaddy-dashcaddy-api:latest');
|
||||||
|
expect(result).toBe(null);
|
||||||
|
|
||||||
|
// First probe must target /v2/dashcaddy/dashcaddy-api/manifests/latest
|
||||||
|
// NOT /v2/library/dashcaddy-dashcaddy-api/manifests/latest
|
||||||
|
const firstPath = calls[0].path;
|
||||||
|
expect(firstPath).toBe('/v2/dashcaddy/dashcaddy-api/manifests/latest');
|
||||||
|
expect(firstPath).not.toContain('library/dashcaddy-dashcaddy-api');
|
||||||
|
|
||||||
|
// The 401 after auth should produce an INFO log about "no upstream"
|
||||||
|
// NOT an error log.
|
||||||
|
const infoMsgs = infoSpy.mock.calls.map((c) => c[1]);
|
||||||
|
expect(infoMsgs).toContain('No upstream registry image — skipping update check');
|
||||||
|
const errorMsgs = errorSpy.mock.calls.map((c) => c[1]);
|
||||||
|
expect(errorMsgs).not.toContain('Docker Hub registry returned HTTP 401 after auth');
|
||||||
|
|
||||||
|
infoSpy.mockRestore();
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('official image (nginx) still probes library/nginx', async () => {
|
||||||
|
const calls = [];
|
||||||
|
um.fetchWithReliability = async (opts) => {
|
||||||
|
calls.push(opts);
|
||||||
|
if (calls.length === 1) {
|
||||||
|
return { statusCode: 200, headers: { 'docker-content-digest': 'sha256:abc123' }, body: '' };
|
||||||
|
}
|
||||||
|
return { statusCode: 200, headers: {}, body: '' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await um.getLatestImageDigest('nginx:latest');
|
||||||
|
expect(result).toBe('sha256:abc123');
|
||||||
|
expect(calls[0].path).toBe('/v2/library/nginx/manifests/latest');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('library/nginx (explicit) probes library/nginx', async () => {
|
||||||
|
const calls = [];
|
||||||
|
um.fetchWithReliability = async (opts) => {
|
||||||
|
calls.push(opts);
|
||||||
|
if (calls.length === 1) {
|
||||||
|
return { statusCode: 200, headers: { 'docker-content-digest': 'sha256:abc' }, body: '' };
|
||||||
|
}
|
||||||
|
return { statusCode: 200, headers: {}, body: '' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await um.getLatestImageDigest('library/nginx:latest');
|
||||||
|
expect(result).toBe('sha256:abc');
|
||||||
|
expect(calls[0].path).toBe('/v2/library/nginx/manifests/latest');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ghcr.io/samiahmed7777/dashcaddy-api uses ghcr.io path (not docker hub)', async () => {
|
||||||
|
const calls = [];
|
||||||
|
um.fetchWithReliability = async (opts) => {
|
||||||
|
calls.push(opts);
|
||||||
|
return { statusCode: 200, headers: { 'docker-content-digest': 'sha256:ghcr' }, body: '' };
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await um.getLatestImageDigest('ghcr.io/samiahmed7777/dashcaddy-api:latest');
|
||||||
|
expect(result).toBe('sha256:ghcr');
|
||||||
|
expect(calls[0].hostname).toBe('ghcr.io');
|
||||||
|
expect(calls[0].path).toBe('/v2/samiahmed7777/dashcaddy-api/manifests/latest');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null + skips cleanly when compose-prefixed image has no upstream', async () => {
|
||||||
|
let callCount = 0;
|
||||||
|
um.fetchWithReliability = async (opts) => {
|
||||||
|
callCount += 1;
|
||||||
|
if (callCount === 1) {
|
||||||
|
return {
|
||||||
|
statusCode: 401,
|
||||||
|
headers: { 'www-authenticate': 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:myproj-myservice:pull"' },
|
||||||
|
body: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { statusCode: 401, headers: {}, body: '{"errors":[{"code":"UNAUTHORIZED"}]}' };
|
||||||
|
};
|
||||||
|
um.fetchAuthToken = async () => 'fake-token';
|
||||||
|
|
||||||
|
const result = await um.getLatestImageDigest('myproj-myservice:latest');
|
||||||
|
expect(result).toBe(null);
|
||||||
|
// Probe targets the correct namespace (myproj/myservice), not library/.
|
||||||
|
const firstCall = await (async () => {
|
||||||
|
let p;
|
||||||
|
um.fetchWithReliability = async (opts) => { p = opts; return { statusCode: 200, headers: {}, body: '' }; };
|
||||||
|
await um.getLatestImageDigest('myproj-myservice:latest');
|
||||||
|
return p;
|
||||||
|
})();
|
||||||
|
expect(firstCall.path).toBe('/v2/myproj/myservice/manifests/latest');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
um.fetchWithReliability = originalFetch;
|
||||||
|
um.fetchAuthToken = originalFetchAuth;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -168,12 +168,39 @@ class UpdateManager extends EventEmitter {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get latest image digest from registry
|
* Get latest image digest from registry
|
||||||
|
*
|
||||||
|
* DC-082: when the image name is a docker-compose prefixed name like
|
||||||
|
* `dashcaddy-dashcaddy-api:latest` (single hyphen-separated, no slash),
|
||||||
|
* the existing code normalized it to `library/dashcaddy-dashcaddy-api`
|
||||||
|
* before probing Docker Hub. The actual upstream namespace for a
|
||||||
|
* compose-prefixed image is `<project>/<service>` (with slash) — Docker
|
||||||
|
* Compose hyphenates the project name and service name when tagging
|
||||||
|
* locally. The pre-fix code probed the wrong repo, Docker Hub returned
|
||||||
|
* HTTP 401 (the repo doesn't exist), and the error log showed
|
||||||
|
* `Docker Hub registry returned HTTP 401 after auth` on every restart
|
||||||
|
* for the local dashcaddy-api image. The fix: split on the FIRST hyphen
|
||||||
|
* for compose-prefixed names so the lookup targets the correct
|
||||||
|
* namespace.
|
||||||
|
*
|
||||||
|
* Compose-prefixed shape: `^[a-z0-9]+-[a-z0-9][a-z0-9_-]*$` (no slash,
|
||||||
|
* lowercase, both halves non-empty). Examples:
|
||||||
|
* dashcaddy-dashcaddy-api -> dashcaddy/dashcaddy-api
|
||||||
|
* myproject-myservice -> myproject/myservice
|
||||||
|
* nginx -> library/nginx (official, unchanged)
|
||||||
|
* library/nginx -> library/nginx (official, unchanged)
|
||||||
|
* dashcaddy/some-image -> dashcaddy/some-image (already has slash)
|
||||||
|
* ghcr.io/x/y -> ghcr.io/x/y (handled below)
|
||||||
*/
|
*/
|
||||||
async getLatestImageDigest(imageName) {
|
async getLatestImageDigest(imageName) {
|
||||||
|
// DC-082: declare `remainder` at the function scope so the catch block
|
||||||
|
// can classify the error against the image-name shape (compose-prefixed
|
||||||
|
// local images produce a steady-state 401 that should log as info, not
|
||||||
|
// error).
|
||||||
|
let remainder = imageName;
|
||||||
try {
|
try {
|
||||||
// Parse image name — strip any leading registry host first
|
// Parse image name — strip any leading registry host first
|
||||||
let imageTag = 'latest';
|
let imageTag = 'latest';
|
||||||
let remainder = imageName;
|
remainder = imageName;
|
||||||
const lastColon = imageName.lastIndexOf(':');
|
const lastColon = imageName.lastIndexOf(':');
|
||||||
// Only treat as tag if the colon is AFTER the last slash (avoids `ghcr.io:443/...`)
|
// Only treat as tag if the colon is AFTER the last slash (avoids `ghcr.io:443/...`)
|
||||||
const lastSlash = imageName.lastIndexOf('/');
|
const lastSlash = imageName.lastIndexOf('/');
|
||||||
@@ -187,8 +214,19 @@ class UpdateManager extends EventEmitter {
|
|||||||
return await this.getGhcrDigest(remainder, imageTag);
|
return await this.getGhcrDigest(remainder, imageTag);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Docker Hub images (library/nginx OR org/image with single slash)
|
// Docker Hub images (library/nginx OR org/image with single slash).
|
||||||
if (!remainder.includes('/') || remainder.split('/').length === 2) {
|
// Special-case docker-compose prefixed names (single hyphen, no slash,
|
||||||
|
// lowercase) — split on the FIRST hyphen to recover the original
|
||||||
|
// `<project>/<service>` namespace. See DC-082.
|
||||||
|
if (!remainder.includes('/')) {
|
||||||
|
const composeRepo = this._composeProjectToRepo(remainder);
|
||||||
|
if (composeRepo) {
|
||||||
|
return await this.getDockerHubDigest(composeRepo, imageTag);
|
||||||
|
}
|
||||||
|
// Not a compose-prefixed name — fall through to the library/ default
|
||||||
|
return await this.getDockerHubDigest(remainder, imageTag);
|
||||||
|
}
|
||||||
|
if (remainder.split('/').length === 2) {
|
||||||
return await this.getDockerHubDigest(remainder, imageTag);
|
return await this.getDockerHubDigest(remainder, imageTag);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,11 +234,72 @@ class UpdateManager extends EventEmitter {
|
|||||||
log.warn('update', 'Custom registry not yet supported', { remainder });
|
log.warn('update', 'Custom registry not yet supported', { remainder });
|
||||||
return null;
|
return null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// DC-082: a "registry returned HTTP 401 after auth" against a
|
||||||
|
// compose-prefixed local image is the steady-state when the image
|
||||||
|
// is built locally and the upstream namespace on Docker Hub
|
||||||
|
// doesn't exist (or is private). The token endpoint returns 200
|
||||||
|
// with an empty-access JWT, and the authed manifest GET 401s.
|
||||||
|
// Log these as a clean info not-found line instead of an error
|
||||||
|
// so dashboards and PagerDuty don't fire on every restart.
|
||||||
|
if (this._isNotPublishedError(error, remainder)) {
|
||||||
|
log.info('update', 'No upstream registry image — skipping update check', { imageName, remainder });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
log.error('update', error, null, { imageName });
|
log.error('update', error, null, { imageName });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DC-082: split a docker-compose prefixed image name on the FIRST hyphen
|
||||||
|
* to recover the original `<project>/<service>` namespace. Returns null
|
||||||
|
* for names that don't match the compose-prefixed shape — callers fall
|
||||||
|
* through to the standard library/-prefixed official-image path.
|
||||||
|
*
|
||||||
|
* Compose-prefixed shape:
|
||||||
|
* - Contains exactly one or more hyphens
|
||||||
|
* - No slash
|
||||||
|
* - Lowercase letters / digits / hyphens / underscores only
|
||||||
|
* - Both halves (before first hyphen, after first hyphen) are non-empty
|
||||||
|
* - First char is a letter or digit (not a hyphen)
|
||||||
|
*/
|
||||||
|
_composeProjectToRepo(remainder) {
|
||||||
|
if (typeof remainder !== 'string' || remainder.length === 0) return null;
|
||||||
|
if (remainder.includes('/')) return null; // already namespaced
|
||||||
|
if (!/^[a-z0-9][a-z0-9_-]*-[a-z0-9][a-z0-9_-]*$/i.test(remainder)) {
|
||||||
|
// Not a compose-prefixed name — let the library/ path handle it
|
||||||
|
// (this is the official-image path: e.g. `nginx`, `alpine`).
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const firstHyphen = remainder.indexOf('-');
|
||||||
|
// Defensive: indexOf must find a hyphen (regex requires it), but guard
|
||||||
|
// against any future regex drift.
|
||||||
|
if (firstHyphen <= 0 || firstHyphen === remainder.length - 1) return null;
|
||||||
|
const project = remainder.substring(0, firstHyphen);
|
||||||
|
const service = remainder.substring(firstHyphen + 1);
|
||||||
|
if (!project || !service) return null;
|
||||||
|
return `${project}/${service}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DC-082: detect the "registry returned 401 after auth" pattern that
|
||||||
|
* signals "this image has no public upstream on Docker Hub" (as opposed
|
||||||
|
* to a genuine auth failure or transient network error). Steady-state
|
||||||
|
* for compose-prefixed local images that aren't published.
|
||||||
|
*/
|
||||||
|
_isNotPublishedError(error, remainder) {
|
||||||
|
if (!error || typeof error.message !== 'string') return false;
|
||||||
|
if (!error.message.includes('HTTP 401')) return false;
|
||||||
|
// Constrain to the compose-prefixed path — a real auth failure on a
|
||||||
|
// legitimate `library/foo` or `namespace/foo` probe should still log
|
||||||
|
// as an error (it never auto-heals).
|
||||||
|
if (typeof remainder !== 'string' || remainder.includes('/')) return false;
|
||||||
|
if (!/^[a-z0-9][a-z0-9_-]*-[a-z0-9][a-z0-9_-]*$/i.test(remainder)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get image digest from GitHub Container Registry (ghcr.io)
|
* Get image digest from GitHub Container Registry (ghcr.io)
|
||||||
* Public images are tokenless via the registry-1.docker.io-style bearer flow,
|
* Public images are tokenless via the registry-1.docker.io-style bearer flow,
|
||||||
|
|||||||
Reference in New Issue
Block a user