Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 14 additions & 18 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions docs/branch-protection.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
37 changes: 34 additions & 3 deletions docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}:<version>`.
`ghcr.io/agent-field/af-stack-{runtime,dashboard,customer-app,supportdesk-agent}:<version>`
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.

Expand Down Expand Up @@ -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.
83 changes: 83 additions & 0 deletions scripts/assert-ghcr-public.sh
Original file line number Diff line number Diff line change
@@ -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
79 changes: 79 additions & 0 deletions scripts/publish-ghcr-packages.sh
Original file line number Diff line number Diff line change
@@ -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' ' ' </tmp/ghcr-vis-err | head -c 300)"
echo "FAIL ${pkg} — API cannot change visibility (${err})"
echo " Open ${settings} → Change visibility → Public"
failed="${failed} ${pkg}"
done

if [ -n "$failed" ]; then
echo
echo "Still private:${failed}"
echo "The REST API cannot change visibility for some org-owned GHCR"
echo "packages. An org owner must flip them once in the UI; later"
echo "releases reuse the same names and stay public."
exit 1
fi
Loading