From 37cec538d4429b09516f2a330b79c8ec3052bfac Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 2 Sep 2026 18:42:42 +0000 Subject: [PATCH] fix(release): assert GHCR images are actually anonymously pullable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The smoke gate crashed on the first private package's 401 (curl -f plus JSON parse) before it could list which images af-stack init cannot pull. It would also have 404'd public OCI manifests after packages were made public, because the Accept header omitted vnd.oci.image.manifest. After each image push, try to set the GHCR package public. The REST API often 404s for org-owned containers — that is a one-time org-owner click in package settings, documented in docs/releasing.md. Signed-off-by: Cursor Agent Co-authored-by: Santosh kumar --- .github/workflows/release.yml | 32 ++++++------ docs/branch-protection.md | 7 +++ docs/releasing.md | 37 ++++++++++++-- scripts/assert-ghcr-public.sh | 83 ++++++++++++++++++++++++++++++++ scripts/publish-ghcr-packages.sh | 79 ++++++++++++++++++++++++++++++ 5 files changed, 217 insertions(+), 21 deletions(-) create mode 100755 scripts/assert-ghcr-public.sh create mode 100755 scripts/publish-ghcr-packages.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0ed1cd8..24bf782 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -153,6 +153,15 @@ jobs: cache-from: type=gha,scope=${{ matrix.name }} cache-to: type=gha,mode=max,scope=${{ matrix.name }} provenance: false + # GHCR packages are private on first push. af-stack init pulls with + # no login, so we try to flip visibility here (packages:write). The + # REST API 404s for some org-owned packages — continue, and let the + # anonymous-pull assert in smoke fail closed with the UI path. + - name: Make ghcr.io/agent-field/af-stack-${{ matrix.name }} public + continue-on-error: true + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: scripts/publish-ghcr-packages.sh "af-stack-${{ matrix.name }}" smoke: name: Smoke — boot runtime image @@ -218,26 +227,13 @@ jobs: -o /tmp/af-stack ./services/cli/cmd/af-stack /tmp/af-stack version || true - name: Assert the release images are publicly pullable - # `af-stack init` apps pull these with NO registry login, the way any - # user does. GHCR creates packages private by default, so a release - # whose images are private ships a CLI whose scaffolds cannot boot. - # Fix: github.com/orgs/Agent-Field/packages → the package → Package - # settings → Change visibility → Public, then re-run this job. + # `af-stack init` apps pull these with NO registry login. Private + # GHCR packages must fail this job — do not skip or warn. The + # script lists every private package (it must not die on the first + # 401) and prints the org package-settings URLs. env: AF_STACK_VERSION: ${{ needs.prepare.outputs.version }} - run: | - set -euo pipefail - private="" - for svc in runtime dashboard customer-app supportdesk-agent; do - token="$(curl -fsS "https://ghcr.io/token?scope=repository:agent-field/af-stack-$svc:pull" | python3 -c 'import sys,json;print(json.load(sys.stdin)["token"])')" - code="$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $token" -H 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.docker.distribution.manifest.v2+json' "https://ghcr.io/v2/agent-field/af-stack-$svc/manifests/$AF_STACK_VERSION")" - echo "ghcr.io/agent-field/af-stack-$svc:$AF_STACK_VERSION anonymous pull: HTTP $code" - [ "$code" = "200" ] || private="$private af-stack-$svc" - done - if [ -n "$private" ]; then - echo "::error::these GHCR packages are not publicly pullable:$private — make each one Public under the org's package settings and re-run this job; until then apps from \`af-stack init\` cannot boot their bundled backend" - exit 1 - fi + run: scripts/assert-ghcr-public.sh - name: Log out of GHCR so the scaffold pulls anonymously, like a user run: docker logout ghcr.io || true - name: Scaffold a standalone app diff --git a/docs/branch-protection.md b/docs/branch-protection.md index 9f8789f..2d89b50 100644 --- a/docs/branch-protection.md +++ b/docs/branch-protection.md @@ -54,3 +54,10 @@ gh api repos/Agent-Field/BackAI/branches/main --jq '{name,protected,protection}' ``` `protected` should be `true` (or a ruleset should list `main`). + +## GHCR packages (separate from branch rules) + +Branch protection does not make container images public. GHCR packages +default to private; `af-stack init` pulls them anonymously. An org owner +must set `af-stack-{runtime,dashboard,customer-app,supportdesk-agent}` +to **Public** once — see [releasing.md](releasing.md#ghcr-package-visibility). diff --git a/docs/releasing.md b/docs/releasing.md index 9f51d3a..5d2d346 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -16,10 +16,13 @@ Releases are **automatic on merge to `main`**. You never hand-cut a version. (Conventional Commits — see below). If nothing release-worthy changed, it stops here (no release). - **Builds & pushes** the container images to - `ghcr.io/agent-field/af-stack-{runtime,dashboard,customer-app}:`. + `ghcr.io/agent-field/af-stack-{runtime,dashboard,customer-app,supportdesk-agent}:` + and tries to mark each GHCR package public (see + [GHCR package visibility](#ghcr-package-visibility)). - **Smoke-boots the runtime image** against a real Postgres + MinIO and waits - for `/ready`. If the image can't boot or migrate, the release is aborted - before anything is published — this is the regression gate. + for `/ready`. Then it asserts every image is anonymously pullable and + scaffolds an app with `af-stack init` + `npm start`. If any of those + fail, the release is aborted before anything is published. - **Tags** `vX.Y.Z`, cuts a **GitHub Release** with cross-compiled `af-stack` CLI binaries + a changelog (GoReleaser), and moves the `:latest` image tag. @@ -64,3 +67,31 @@ The pipeline runs entirely on the built-in `GITHUB_TOKEN` (ghcr + releases) — extra secrets are required. Images publish under the repository's own org (`ghcr.io/agent-field/…`). Multi-arch (arm64) images and Homebrew/Scoop taps are deferred follow-ups (see `docs/cli-distribution.md`). + +## GHCR package visibility + +`af-stack init` writes a `docker-compose.yml` that pulls the four images +**without** a registry login. GHCR creates packages **private** on first +push, even when this repo is public. A private image means every +scaffolded app fails to boot. + +The Release workflow tries `scripts/publish-ghcr-packages.sh` after each +push. The GitHub REST API often cannot change visibility for org-owned +container packages (PATCH returns 404). When that happens, an org owner +does this **once** (later releases reuse the same names and stay public): + +1. Open [github.com/orgs/Agent-Field/packages](https://github.com/orgs/Agent-Field/packages). +2. For `af-stack-runtime`, `af-stack-dashboard`, `af-stack-customer-app`, + and `af-stack-supportdesk-agent`: **Package settings → Change + visibility → Public**. +3. Re-run **Actions → Release → Run workflow**. + +Or, authenticated as an org owner / package admin: + +```bash +scripts/publish-ghcr-packages.sh +``` + +`scripts/assert-ghcr-public.sh` is the smoke gate: it must list every +private package (it must not crash on the first 401) and fail the +release until they are public. diff --git a/scripts/assert-ghcr-public.sh b/scripts/assert-ghcr-public.sh new file mode 100755 index 0000000..0a25c07 --- /dev/null +++ b/scripts/assert-ghcr-public.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# Assert GHCR images are anonymously pullable (no docker login). +# +# `af-stack init` apps pull these with no registry credentials. GHCR creates +# packages private by default, so a release whose images are private ships a +# CLI whose scaffolds cannot boot. +# +# Usage: +# AF_STACK_VERSION=0.13.0 scripts/assert-ghcr-public.sh +# AF_STACK_VERSION=0.13.0 scripts/assert-ghcr-public.sh runtime dashboard +# +# Do not use `curl -f` against the anonymous token endpoint — private +# packages return HTTP 401 with an empty body, and -f + JSON.parse crashes +# before the script can list which packages are private. +set -euo pipefail + +VERSION="${AF_STACK_VERSION:?set AF_STACK_VERSION to the image tag to check}" +NAMESPACE="${GHCR_NAMESPACE:-agent-field}" +ORG="${GHCR_ORG:-Agent-Field}" + +if [ "$#" -eq 0 ]; then + set -- runtime dashboard customer-app supportdesk-agent +fi + +token_file="$(mktemp)" +trap 'rm -f "$token_file"' EXIT + +private="" +for svc in "$@"; do + repo="${NAMESPACE}/af-stack-${svc}" + image="ghcr.io/${repo}:${VERSION}" + + token_code="$(curl -sS -o "$token_file" -w '%{http_code}' \ + "https://ghcr.io/token?scope=repository:${repo}:pull" || echo "000")" + if [ "$token_code" != "200" ]; then + echo "${image} anonymous token: HTTP ${token_code} (private or missing)" + private="${private} af-stack-${svc}" + continue + fi + + token="$(python3 -c ' +import json, sys +try: + data = json.load(open(sys.argv[1])) +except Exception: + raise SystemExit(0) +print(data.get("token") or "") +' "$token_file")" + if [ -z "$token" ]; then + echo "${image} anonymous token: empty body" + private="${private} af-stack-${svc}" + continue + fi + + # build-push-action with provenance:false still publishes an OCI image + # manifest. Without vnd.oci.image.manifest.v1+json, GHCR returns 404 + # MANIFEST_UNKNOWN even for a public, pullable tag. + code="$(curl -sS -o /dev/null -w '%{http_code}' \ + -H "Authorization: Bearer ${token}" \ + -H 'Accept: application/vnd.oci.image.manifest.v1+json, application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.docker.distribution.manifest.v2+json' \ + "https://ghcr.io/v2/${repo}/manifests/${VERSION}" || echo "000")" + echo "${image} anonymous pull: HTTP ${code}" + if [ "$code" != "200" ]; then + private="${private} af-stack-${svc}" + fi +done + +if [ -n "$private" ]; then + echo + echo "::error::these GHCR packages are not publicly pullable:${private} — make each one Public under the org's package settings and re-run Release; until then apps from \`af-stack init\` cannot boot their bundled backend" + echo + echo "GHCR creates packages private by default. One-time fix (org owner):" + echo " 1. https://github.com/orgs/${ORG}/packages" + echo " 2. Each package above → Package settings → Change visibility → Public" + echo " 3. Or run: scripts/publish-ghcr-packages.sh" + echo " 4. Re-run the Release workflow (Actions → Release → Run workflow)." + echo + echo "Settings URLs:" + for pkg in $private; do + echo " https://github.com/orgs/${ORG}/packages/container/package/${pkg}/settings" + done + exit 1 +fi diff --git a/scripts/publish-ghcr-packages.sh b/scripts/publish-ghcr-packages.sh new file mode 100755 index 0000000..d735c74 --- /dev/null +++ b/scripts/publish-ghcr-packages.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# Make the release GHCR packages publicly pullable. +# +# GHCR creates packages private on first push. The REST API can change +# visibility for some tokens; org-owned container packages often 404 even +# for admins — in that case this script prints the UI path and exits 1. +# +# Requires a token with write:packages (and package admin) on the org. +# GITHUB_TOKEN from Actions may work after the workflow that published the +# package; cloud-agent tokens typically cannot. A one-time org-owner click +# in the package settings is the reliable fallback. +# +# Usage: +# scripts/publish-ghcr-packages.sh +# scripts/publish-ghcr-packages.sh af-stack-runtime af-stack-dashboard +set -euo pipefail + +if ! command -v gh >/dev/null 2>&1; then + echo "gh CLI is required" >&2 + exit 1 +fi + +ORG="${GHCR_ORG:-Agent-Field}" + +if [ "$#" -eq 0 ]; then + set -- af-stack-runtime af-stack-dashboard af-stack-customer-app af-stack-supportdesk-agent +fi + +failed="" +for pkg in "$@"; do + settings="https://github.com/orgs/${ORG}/packages/container/package/${pkg}/settings" + vis="" + # Retry GET — a just-pushed package can 404 for a few seconds. + # gh prints the error JSON on stdout even when it exits non-zero; only + # keep a real visibility enum so we don't treat "Package not found" as one. + for attempt in 1 2 3; do + got="$(gh api "orgs/${ORG}/packages/container/${pkg}" --jq .visibility 2>/dev/null || true)" + case "$got" in + public|private|internal) + vis="$got" + break + ;; + esac + if [ "$attempt" -lt 3 ]; then + sleep $((attempt * 2)) + fi + done + + if [ "$vis" = "public" ]; then + echo "OK ${pkg} already public" + continue + fi + + if [ -n "$vis" ]; then + echo "… ${pkg} is ${vis}; trying PATCH visibility=public" + else + echo "… ${pkg} not readable via API; trying PATCH visibility=public" + fi + + if gh api --method PATCH "orgs/${ORG}/packages/container/${pkg}" \ + -f visibility=public >/dev/null 2>/tmp/ghcr-vis-err; then + echo "OK ${pkg} set public" + continue + fi + + err="$(tr '\n' ' '