const core = require('@actions/core'); const http = require('http'); const https = require('https'); const querystring = require('querystring'); const form_urlencoded = require('form-urlencoded'); const req_timeout_ms = 30_000; const manifest_media_type = 'application/vnd.docker.distribution.manifest.v2+json'; main(); async function main() { try { const input = { registry: core.getInput('registry'), registry_url: null, insecure_registry: core.getInput('insecure-registry') === 'true', image: core.getInput('image'), old_tag: core.getInput('old-tag'), new_tags: core.getInput('new-tags').trim().split('\n'), username: core.getInput('username'), password: core.getInput('password'), token: null, }; input.registry_url = `http${input.insecure_registry ? '' : 's'}://${input.registry}`; console.log('Tagging %s/%s:%s with new tags', input.registry, input.image, input.old_tag, input.new_tags); const manifest = await get_manifest(input); const promises = input.new_tags.map((new_tag) => { return put_manifest(new_tag, manifest, input); }); await Promise.all(promises); } catch (error) { console.error(error); core.setFailed(error.message); } } async function get_manifest(input) { const path = `/v2/${input.image}/manifests/${input.old_tag}`; let { status, headers, body } = await http_req('GET', input.registry_url + path, { accept: manifest_media_type, }); if (status === 401 && headers['www-authenticate']) { await get_token(headers['www-authenticate'], input); ({ status, headers, body } = await http_req(input.insecure_registry, 'GET', input.registry_url + path, { accept: manifest_media_type, authorization: `Bearer ${input.token}`, })); } if (status !== 200) { console.error('get manifest response', { status, headers, body }); throw new Error('failed to fetch existing manifest'); } return body; } async function put_manifest(new_tag, manifest, input) { const path = `/v2/${input.image}/manifests/${new_tag}`; const req_headers = { 'content-type': manifest_media_type, }; if (token) { req_headers.authorization = `Bearer ${input.token}`; } const { status, headers, body } = await http_req('PUT', input.registry_url + path, req_headers, manifest); if (status >= 400) { console.error('get manifest response', { status, headers, body }); throw new Error('failed to put manifest'); } } async function get_token(www_authenticate, input) { const params = parse_www_authenticate(www_authenticate); if (! input.username || ! input.password) { throw new Error('registry requested authentication, but not credentials were provided'); } const query_params = { service: params.service, scope: params.scope, grant_type: 'password', username: input.username, password: input.password, }; const { status, headers, body } = await http_req('POST', params.realm, { 'content-type': 'application/x-www-form-urlencoded', }, form_urlencoded(query_params)); if (status !== 200) { console.error('get token response', { status, headers, body }); throw new Error('error response from get token request'); } try { const { token } = JSON.parse(body); input.token = token; } catch (error) { throw new Error('invalid response from get token request'); } } async function http_req(method, url_str, headers, body) { const url = new URL(url_str); const make_request = url.protocol === 'https:' ? https.request : url.protocol === 'http:' ? http.request : null; if (! make_request) { throw new Error('registry URL protocol not http(s)'); } if (url.username || url.password) { throw new Error('urls containing user credentials not allowed'); } const result = { url, status: null, body: null, headers: null, }; const path = url.pathname + (url.search || ''); const port = url.port ? parseInt(url.port, 10) : (url.protocol === 'https:' ? 443 : 80); return new Promise((resolve, reject) => { const req = make_request({ method: method, protocol: url.protocol, hostname: url.hostname, port: port, path: path, headers: headers }, (res) => { result.status = res.statusCode; result.body = ''; res.on('data', (chunk) => { result.body += chunk; }); res.on('end', () => { result.headers = res.headers; resolve(result); }); }); req.setTimeout(req_timeout_ms, () => { req.destroy(); reject(new Error('request timeout')); }); req.on('error', (error) => { reject(error); }); if (body) { req.write(body); } req.end(); }); } function parse_www_authenticate(www_authenticate) { if (! www_authenticate.startsWith('Bearer ')) { throw new Error('invalid www-authenticate header (no "Bearer " prefix)'); } let remaining = www_authenticate.slice(7); const parameters = Object.create(null); while (remaining) { const eq_index = remaining.indexOf('='); if (eq_index < 0) { parameters[remaining] = ''; break; } const key = remaining.slice(0, eq_index); remaining = remaining.slice(eq_index + 1); if (! remaining) { parameters[key] = ''; break; } if (remaining[0] === '"') { const close_index = remaining.slice(1).indexOf('"'); if (close_index < 0) { throw new Error('invalid www-authenticate header (unclosed quotes)'); } parameters[key] = remaining.slice(1, close_index); remaining = remaining.slice(close_index + 1); if (remaining && remaining[0] !== ',') { throw new Error('invalid www-authenticate header (expected comma after closing quote)'); } remaining = remaining.slice(1); } else { const comma_index = remaining.indexOf(','); if (comma_index < 0) { parameters[key] = remaining; break; } parameters[key] = remaining.slice(1, comma_index); remaining = remaining.slice(comma_index + 1); } } const { realm, service, scope } = parameters; if (! realm || ! service || ! scope) { throw new Error('invalid www-authenticate header (missing "realm", "service", or "scope")'); } return parameters; }