diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml index 3655600..b784eb5 100644 --- a/.github/workflows/build-image.yml +++ b/.github/workflows/build-image.yml @@ -39,8 +39,11 @@ jobs: uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 with: images: ghcr.io/${{ github.repository }} + # Gate :latest on the main branch explicitly, NOT {{is_default_branch}}: + # under GitFlow the repo's default branch is `develop`, so is_default_branch + # is false on the release push to main and :latest never advanced (fix 2026-07-12). tags: | - type=raw,value=latest,enable={{is_default_branch}} + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} type=sha,format=long type=ref,event=tag @@ -66,8 +69,9 @@ jobs: uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 with: images: ghcr.io/${{ github.repository }} + # Same main-branch gate as the app image above (not {{is_default_branch}}). tags: | - type=raw,value=migrate,enable={{is_default_branch}} + type=raw,value=migrate,enable=${{ github.ref == 'refs/heads/main' }} type=sha,format=long,prefix=migrate- - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 diff --git a/.github/workflows/retag-mutable-tags.yml b/.github/workflows/retag-mutable-tags.yml new file mode 100644 index 0000000..fc280c6 --- /dev/null +++ b/.github/workflows/retag-mutable-tags.yml @@ -0,0 +1,84 @@ +name: retag-mutable-tags + +# Break-glass: re-point the mutable :latest (app) and :migrate (DB-tooling) GHCR +# tags at an already-published immutable release build (:sha- / :migrate-) +# without rebuilding — a server-side manifest copy via `docker buildx imagetools +# create`. Use when a release's mutable tags did not advance (e.g. the 2026-07-12 +# is_default_branch bug, fixed in build-image.yml) or to roll :latest back to a +# known-good release sha. +# +# Security posture mirrors build-image.yml: the operator-supplied `sha` input is +# passed via env and regex-validated (never interpolated into a run: shell), the +# action is pinned to an immutable SHA, and GITHUB_TOKEN is packages:write only. + +on: + workflow_dispatch: + inputs: + sha: + description: 'Full 40-char commit SHA of the release whose :sha- / :migrate- images become :latest / :migrate' + required: true + type: string + +concurrency: + group: retag-mutable-tags + cancel-in-progress: false + +permissions: + contents: read + packages: write + +jobs: + retag: + name: re-point :latest and :migrate + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Re-point mutable tags (server-side manifest copy) + env: + SHA: ${{ inputs.sha }} + IMAGE: ghcr.io/${{ github.repository }} + run: | + set -euo pipefail + if [[ ! "$SHA" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::sha must be a 40-char lowercase hex commit SHA (got: '$SHA')" + exit 1 + fi + echo "Re-pointing $IMAGE:latest -> $IMAGE:sha-$SHA" + docker buildx imagetools create --tag "$IMAGE:latest" "$IMAGE:sha-$SHA" + echo "Re-pointing $IMAGE:migrate -> $IMAGE:migrate-$SHA" + docker buildx imagetools create --tag "$IMAGE:migrate" "$IMAGE:migrate-$SHA" + + - name: Verify mutable tags resolve to the release image (amd64 manifest) + env: + SHA: ${{ inputs.sha }} + IMAGE: ghcr.io/${{ github.repository }} + run: | + set -euo pipefail + # Compare the linux/amd64 child-manifest digest (what the box actually + # runs), not the top-level index digest — imagetools reserializes the + # index, so index digests can differ even on a correct retag. + amd64() { + docker buildx imagetools inspect "$1" \ + --format '{{range .Manifest.Manifests}}{{if eq .Platform.Architecture "amd64"}}{{println .Digest}}{{end}}{{end}}' \ + | head -n1 + } + fail=0 + for pair in "latest:sha-$SHA" "migrate:migrate-$SHA"; do + mut="${pair%%:*}"; imm="${pair#*:}" + md="$(amd64 "$IMAGE:$mut")"; id="$(amd64 "$IMAGE:$imm")" + echo ":$mut amd64 = $md" + echo ":$imm amd64 = $id" + if [[ -n "$md" && "$md" == "$id" ]]; then + echo "OK: :$mut now resolves to :$imm" + else + echo "::error::$IMAGE:$mut amd64 digest ($md) != $IMAGE:$imm ($id)" + fail=1 + fi + done + exit "$fail" diff --git a/app/(control)/admin/onboard/page.tsx b/app/(control)/admin/onboard/page.tsx index bf29dd5..47aac77 100644 --- a/app/(control)/admin/onboard/page.tsx +++ b/app/(control)/admin/onboard/page.tsx @@ -2,13 +2,32 @@ import { getTranslations } from "next-intl/server"; import { requireAdmin } from "@/lib/rbac"; import { controlLocale } from "@/lib/control-locale"; import { mollieConfigured } from "@/lib/mollie"; +import { db } from "@/lib/db"; +import { loadTenantRegistry } from "@/lib/tenant-registry"; +import { toOnboardTenants, type OnboardTenant } from "@/lib/onboard-tenants"; import OnboardPartnerForm from "@/components/control/OnboardPartnerForm"; +// The registry file changes underneath us (rsync on deploy-repo push) and the +// billing join must reflect the live DB — always re-read, never a build snapshot +// (mirrors /admin/tenants). +export const dynamic = "force-dynamic"; + export default async function AdminOnboardPage() { await requireAdmin(); const locale = await controlLocale(); const t = await getTranslations({ locale, namespace: "control.admin" }); + // Registry⋈billing join (same pattern as /admin/tenants): compute which + // registry tenants are partner-free so the form can offer a picker. If the + // registry can't be read, fall back to the free-text form + an inline note. + const registry = await loadTenantRegistry(); + let tenants: OnboardTenant[] | undefined; + if (registry.ok) { + const billings = await db.tenantBilling.findMany({ select: { tenantSlug: true } }); + const onboardedSlugs = new Set(billings.map((b) => b.tenantSlug)); + tenants = toOnboardTenants(registry.tenants, onboardedSlugs); + } + return (
@@ -22,8 +41,14 @@ export default async function AdminOnboardPage() {

)} + {!registry.ok && ( +

+ {t("onboard.registryUnavailable", { error: registry.error })} +

+ )} +
- +
); diff --git a/components/control/OnboardPartnerForm.tsx b/components/control/OnboardPartnerForm.tsx index 83231d1..64adb5e 100644 --- a/components/control/OnboardPartnerForm.tsx +++ b/components/control/OnboardPartnerForm.tsx @@ -1,18 +1,44 @@ "use client"; -import { useActionState } from "react"; +import { useActionState, useState } from "react"; import { useTranslations } from "next-intl"; import { onboardPartnerAction, type OnboardActionState } from "@/lib/actions/onboarding-actions"; +import { type OnboardTenant, isOnboardable } from "@/lib/onboard-tenants"; import ActionError from "./ActionError"; import CopyField from "./CopyField"; -export default function OnboardPartnerForm() { +/** + * Admin onboarding form. When the page can read the tenant registry it passes + * `tenants` and the form drives the tenant from a registry picker (partner-free + * by default, pre-filling restaurant name + go-live date). When the registry is + * unavailable `tenants` is undefined and it falls back to free-text inputs — the + * server action + slug regex are identical in both modes. + */ +export default function OnboardPartnerForm({ tenants }: Readonly<{ tenants?: OnboardTenant[] }>) { const t = useTranslations("control.admin"); const [state, action, pending] = useActionState( onboardPartnerAction, {}, ); + // Registry-driven picker state. `slug` also keys the pre-filled restaurant + // name + date inputs so selecting a tenant remounts them with fresh defaults + // while leaving them editable. + const [slug, setSlug] = useState(""); + const [showAll, setShowAll] = useState(false); + const registryMode = Array.isArray(tenants); + const selectable = tenants?.filter(isOnboardable) ?? []; + const visible = showAll ? (tenants ?? []) : selectable; + const selected = tenants?.find((tn) => tn.slug === slug); + + const optionLabel = (tn: OnboardTenant) => { + const cityPart = tn.city ? ` · ${tn.city}` : ""; + let badge = ""; + if (tn.onboarded) badge = ` (${t("onboard.onboardedBadge")})`; + else if (tn.status !== "active") badge = ` (${tn.status})`; + return `${tn.slug} — ${tn.name}${cityPart}${badge}`; + }; + return (
- - + + {registryMode ? ( + <> + + + {selectable.length === 0 && !showAll && ( +

+ {t("onboard.noTenants")} +

+ )} + {selected && ( +

+ {[selected.city, selected.currency ?? "EUR"].filter(Boolean).join(" · ")} + {selected.liveSince ? ` · ${t("onboard.liveSincePrefix")} ${selected.liveSince}` : ""} +

+ )} + + + ) : ( + <> + + + + )} +