@@ -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