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