Skip to content

Commit 6c3fb28

Browse files
committed
Seed GHCR catalog package during init canonical --setup-github
The on-merge workflow pushes catalog images to ghcr.io/<org>/<repo>-catalog, but the first push fails because the GHCR package doesn't exist yet and creating org packages requires elevated permissions. Add EnsureCatalogPackage to the setup flow that: 1. Checks if the package already exists 2. If not, builds and pushes a minimal scratch image to create it 3. Links the package to the canonical repo so workflow tokens can push Also fix the workflow template to scope the app token to the org owner and use it for GHCR login.
1 parent f3c00f4 commit 6c3fb28

1 file changed

Lines changed: 93 additions & 2 deletions

File tree

internal/github/setup.go

Lines changed: 93 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -306,15 +306,22 @@ func SetupCanonicalRepo(client *githubauth.Client, org, repo, appID, pemPath, si
306306
return nil, err
307307
}
308308

309-
// 5. GitHub Pages
309+
// 5. GHCR catalog package — seed + link to repo
310+
if err := EnsureCatalogPackage(client, org, repo, res); err != nil {
311+
// Non-fatal: the on-merge workflow will create the package on first
312+
// successful push. Log warning and continue.
313+
ui.Warning("GHCR catalog package setup: %v", err)
314+
}
315+
316+
// 6. GitHub Pages
310317
if err := EnsureGitHubPages(client, org, repo, res); err != nil {
311318
return nil, err
312319
}
313320
if err := ConfigurePagesVisibility(client, org, repo, res); err != nil {
314321
return nil, err
315322
}
316323

317-
// 6. Custom domain (when configured)
324+
// 7. Custom domain (when configured)
318325
if siteURL != "" {
319326
if dnsErr := CheckDNSForPages(org, siteURL); dnsErr != nil {
320327
ui.Warning("DNS: %v", dnsErr)
@@ -327,6 +334,90 @@ func SetupCanonicalRepo(client *githubauth.Client, org, repo, appID, pemPath, si
327334
return res, nil
328335
}
329336

337+
// ---------------------------------------------------------------------------
338+
// GHCR catalog package
339+
// ---------------------------------------------------------------------------
340+
341+
// EnsureCatalogPackage ensures the GHCR container package for the catalog
342+
// exists and is linked to the canonical repo. The on-merge workflow pushes
343+
// catalog images to ghcr.io/<org>/<repo>-catalog; if the package doesn't
344+
// exist, the first push fails with "installation not allowed to Create
345+
// organization package". This function seeds an empty image to create the
346+
// package, then links it to the repo so workflow tokens can push.
347+
func EnsureCatalogPackage(client *githubauth.Client, org, repo string, res *SetupResult) error {
348+
pkgName := repo + "-catalog"
349+
350+
// Check if package already exists.
351+
endpoint := fmt.Sprintf("/orgs/%s/packages/container/%s", org, pkgName)
352+
_, status, err := client.Get(endpoint)
353+
if err == nil && status < 300 {
354+
res.Add("skipped", fmt.Sprintf("GHCR package %s (already exists)", pkgName))
355+
return linkPackageToRepo(client, org, repo, pkgName, res)
356+
}
357+
358+
// Package doesn't exist — create it by pushing a minimal OCI image
359+
// via docker CLI. The client token may not work for GHCR registry
360+
// pushes (requires docker login), so we shell out.
361+
ui.Info("Creating GHCR package %s/%s...", org, pkgName)
362+
363+
image := fmt.Sprintf("ghcr.io/%s/%s:init", strings.ToLower(org), pkgName)
364+
365+
// Build minimal scratch image
366+
tmpDir, mkErr := os.MkdirTemp("", "apx-ghcr-seed-*")
367+
if mkErr != nil {
368+
return fmt.Errorf("creating temp dir: %w", mkErr)
369+
}
370+
defer os.RemoveAll(tmpDir)
371+
372+
dockerfile := filepath.Join(tmpDir, "Dockerfile")
373+
if wErr := os.WriteFile(dockerfile, []byte("FROM scratch\n"), 0o644); wErr != nil {
374+
return fmt.Errorf("writing Dockerfile: %w", wErr)
375+
}
376+
377+
buildCmd := exec.Command("docker", "build", "-t", image, tmpDir)
378+
if out, buildErr := buildCmd.CombinedOutput(); buildErr != nil {
379+
return fmt.Errorf("docker build: %s", strings.TrimSpace(string(out)))
380+
}
381+
382+
pushCmd := exec.Command("docker", "push", image)
383+
if out, pushErr := pushCmd.CombinedOutput(); pushErr != nil {
384+
return fmt.Errorf("docker push: %s (hint: run 'docker login ghcr.io' first)", strings.TrimSpace(string(out)))
385+
}
386+
387+
res.Add("created", fmt.Sprintf("GHCR package %s", pkgName))
388+
return linkPackageToRepo(client, org, repo, pkgName, res)
389+
}
390+
391+
// linkPackageToRepo links a GHCR package to a repository so that
392+
// workflow tokens scoped to the repo can push to the package.
393+
func linkPackageToRepo(client *githubauth.Client, org, repo, pkgName string, res *SetupResult) error {
394+
// Get repo ID
395+
repoEndpoint := fmt.Sprintf("/repos/%s/%s", org, repo)
396+
body, status, err := client.Get(repoEndpoint)
397+
if err != nil || status >= 300 {
398+
return fmt.Errorf("looking up repo %s/%s: HTTP %d", org, repo, status)
399+
}
400+
var repoInfo struct {
401+
ID int `json:"id"`
402+
}
403+
if jErr := json.Unmarshal(body, &repoInfo); jErr != nil {
404+
return fmt.Errorf("parsing repo response: %w", jErr)
405+
}
406+
407+
// Link package to repo
408+
linkEndpoint := fmt.Sprintf("/orgs/%s/packages/container/%s/repository/%d", org, pkgName, repoInfo.ID)
409+
_, status, err = client.Put(linkEndpoint, nil)
410+
if err != nil {
411+
return fmt.Errorf("linking package to repo: %w", err)
412+
}
413+
if status >= 300 && status != 409 { // 409 = already linked
414+
return fmt.Errorf("linking package to repo: HTTP %d", status)
415+
}
416+
417+
res.Add("linked", fmt.Sprintf("GHCR package %s → %s/%s", pkgName, org, repo))
418+
return nil
419+
}
420+
330421
// ---------------------------------------------------------------------------
331422
// GitHub Pages
332423
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)