Ctrl K
ComunidadGuías…

scripts/publish-images.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// Registry-backed release recovery. build-<version> tags retain the original
// manifests; Actions artifacts and runner disks are not the release ledger.
import { createHash } from 'node:crypto';
import { appendFileSync, mkdirSync, writeFileSync } from 'node:fs';
import { pathToFileURL } from 'node:url';

const IMAGE_TYPES = [
  'application/vnd.oci.image.manifest.v1+json',
  'application/vnd.docker.distribution.manifest.v2+json',
];
const INDEX_TYPES = [
  'application/vnd.oci.image.index.v1+json',
  'application/vnd.docker.distribution.manifest.list.v2+json',
];
const ACCEPT = [...IMAGE_TYPES, ...INDEX_TYPES].join(', ');
const IMAGES = ['backend', 'frontend'];
const hash = bytes => `sha256:${createHash('sha256').update(bytes).digest('hex')}`;

export class Registry {
  constructor(repository, { actor, password, fetchImpl = fetch } = {}) {
    this.repository = repository;
    this.actor = actor;
    this.password = password;
    this.fetch = fetchImpl;
  }

  async request(path, options = {}) {
    if (!this.token) {
      const query = new URLSearchParams({ service: 'ghcr.io', scope: `repository:${this.repository}:pull,push` });
      const response = await this.fetch(`https://ghcr.io/token?${query}`, {
        headers: { Authorization: `Basic ${Buffer.from(`${this.actor}:${this.password}`).toString('base64')}` },
        signal: AbortSignal.timeout(30_000),
      });
      if (!response.ok) throw new Error(`Registry authentication failed (${response.status})`);
      this.token = (await response.json()).token;
      if (!this.token) throw new Error('Registry authentication returned no token');
    }
    return this.fetch(`https://ghcr.io/v2/${this.repository}/${path}`, {
      ...options,
      headers: { Authorization: `Bearer ${this.token}`, Accept: ACCEPT, ...options.headers },
      signal: AbortSignal.timeout(30_000),
    });
  }

  async manifest(ref) {
    const response = await this.request(`manifests/${ref}`);
    // A failed lookup is NOT permission to rebuild. Only a structured registry
    // "missing" response counts; auth errors, HTML 404s and outages fail closed.
    if (response.status === 404) {
      const body = await response.json();
      if (Array.isArray(body.errors) && body.errors.length > 0 &&
          body.errors.every(error => ['MANIFEST_UNKNOWN', 'NAME_UNKNOWN'].includes(error.code))) return null;
    }
    if (response.status !== 200) throw new Error(`Cannot inspect ${this.repository}:${ref} (${response.status})`);
    const raw = Buffer.from(await response.arrayBuffer());
    const digest = hash(raw);
    if (response.headers.get('docker-content-digest') !== digest ||
        (ref.startsWith('sha256:') && ref !== digest)) throw new Error(`Manifest digest mismatch for ${this.repository}:${ref}`);
    const document = JSON.parse(raw.toString());
    if (document.schemaVersion !== 2 || ![...IMAGE_TYPES, ...INDEX_TYPES].includes(document.mediaType)) {
      throw new Error(`Unsupported manifest for ${this.repository}:${ref}`);
    }
    return { raw, digest, document };
  }

  async identity(manifest) {
    let image = manifest;
    if (INDEX_TYPES.includes(image.document.mediaType)) {
      // Buildx includes attestation manifests in the index. Read the actual
      // linux/amd64 image config, not an attestation or an arbitrary platform.
      const platforms = image.document.manifests.filter(entry =>
        entry.platform?.os === 'linux' && entry.platform?.architecture === 'amd64');
      if (platforms.length !== 1) throw new Error(`Expected one linux/amd64 image in ${this.repository}`);
      image = await this.manifest(platforms[0].digest);
    }
    if (!image || !IMAGE_TYPES.includes(image.document.mediaType)) throw new Error(`Missing image manifest for ${this.repository}`);
    const digest = image.document.config.digest;
    const response = await this.request(`blobs/${digest}`);
    if (response.status !== 200) throw new Error(`Cannot read image identity for ${this.repository} (${response.status})`);
    const raw = Buffer.from(await response.arrayBuffer());
    if (hash(raw) !== digest) throw new Error(`Image config digest mismatch for ${this.repository}`);
    const config = JSON.parse(raw.toString());
    if (config.os !== 'linux' || config.architecture !== 'amd64') throw new Error(`Unexpected image platform for ${this.repository}`);
    return config.config?.Labels ?? {};
  }

  async tag(tag, manifest) {
    const response = await this.request(`manifests/${tag}`, {
      method: 'PUT', headers: { 'Content-Type': manifest.document.mediaType }, body: manifest.raw,
    });
    if (response.status !== 201 || response.headers.get('docker-content-digest') !== manifest.digest) {
      throw new Error(`Cannot publish ${this.repository}:${tag} (${response.status}); rerun to reconcile saved digests`);
    }
  }
}

export async function prepare(registries, { version, sha }) {
  const result = {};
  for (const image of IMAGES) {
    const registry = registries[image];
    const candidate = await registry.manifest(`build-${version}`);
    const released = await registry.manifest(version);
    if (!candidate && released) throw new Error(`${image}:${version} exists without its saved build; refusing to rebuild`);
    if (candidate) {
      const labels = await registry.identity(candidate);
      if (labels['org.opencontainers.image.version'] !== version || labels['org.opencontainers.image.revision'] !== sha) {
        throw new Error(`${image}:build-${version} belongs to a different version or commit; refusing to rebuild`);
      }
      if (released && released.digest !== candidate.digest) throw new Error(`${image}:${version} differs from its saved digest; refusing to overwrite`);
    }
    result[image] = { candidate, released };
  }
  return result;
}

export async function publish(registries, identity, tags) {
  // Check BOTH repositories before writing either immutable version tag.
  const plan = await prepare(registries, identity);
  if (IMAGES.some(image => !plan[image].candidate)) throw new Error('Both saved image builds are required before publication');
  if (tags.some(tag => ![identity.version, 'latest', 'main'].includes(tag))) throw new Error('Unexpected publication tag');
  for (const image of IMAGES) {
    if (!plan[image].released) await registries[image].tag(identity.version, plan[image].candidate);
  }
  // Floating tags are updated only once both immutable versions exist. The
  // workflow-wide concurrency group serializes this phase across every ref.
  // Registries cannot atomically tag two repositories: a failed PUT is repaired
  // by rerunning with these same retained manifests, without another build.
  for (const tag of tags.filter(tag => tag !== identity.version)) {
    for (const image of IMAGES) {
      const current = await registries[image].manifest(tag);
      if (current?.digest !== plan[image].candidate.digest) await registries[image].tag(tag, plan[image].candidate);
    }
  }
  return plan;
}

async function main() {
  const { OWNER: owner, VERSION: version, SHA: sha, GITHUB_ACTOR: actor, GH_TOKEN: password } = process.env;
  if (!/^[a-z0-9][a-z0-9-]*$/.test(owner ?? '') ||
      !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version ?? '') ||
      !/^[a-f0-9]{40}$/.test(sha ?? '') || !actor || !password) throw new Error('Missing or invalid publication identity/credentials');
  const registries = Object.fromEntries(IMAGES.map(image => [image, new Registry(`${owner}/mike-${image}`, { actor, password })]));
  const identity = { version, sha };
  const command = process.argv[2];
  if (command === 'prepare') {
    const plan = await prepare(registries, identity);
    for (const image of IMAGES) {
      appendFileSync(process.env.GITHUB_OUTPUT, `${image}_digest=${plan[image].candidate?.digest ?? ''}\n`);
      console.log(`${image}: ${plan[image].candidate ? 'reuse saved build' : 'confirmed missing; build required'}`);
    }
  } else if (command === 'publish') {
    const plan = await publish(registries, identity, (process.env.TAGS ?? '').split(/\s+/).filter(Boolean));
    mkdirSync('digests', { recursive: true });
    for (const image of IMAGES) {
      writeFileSync(`digests/${image}`, `${plan[image].candidate.digest}\n`);
      console.log(`${image}:${version} = ${plan[image].candidate.digest}`);
    }
  } else throw new Error('Expected prepare or publish');
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  main().catch(error => { console.error(error.message); process.exitCode = 1; });
}