Skip to content

feat(workflows): reserve image names; build only names a team owns - #1648

Merged
mashkovd merged 10 commits into
mainfrom
fix/reserve-image-names
Oct 11, 2026
Merged

mashkovd merged 10 commits into
mainfrom
fix/reserve-image-names

Conversation

@mashkovd

@mashkovd mashkovd commented Oct 4, 2026 •

Copy link
Copy Markdown
Member

What

Reserve image names, and build only names owned by the requesting team.

Tenant builds push to <registry>/<component_name>, and tenant services run that same name, so the component name is the image name. Until now nothing tied that name to the team asking for it.

The rule

platform-gitops/argo-workflows/scripts/check-image-name.sh [--tenant] [--build] <checkout> <registry> <team> <name> uses POSIX sh, grep and awk, so it runs in busybox (alpine/git) and on GitHub runners. It decides in this order:
0. --tenant (Argo callers, where team is requester-supplied): team must have platform-gitops/tenants/<team>/ and is never platform.

  1. A grant <team> <name> line allows.
  2. A reserved <name> line, or a reserved-prefix match (mctl-), refuses.
  3. A shared <name> line allows a run. It refuses a build (--build) unless step 1 matched.
  4. Another team has services/<other>/<name>, or one of its values.yaml runs <registry>/<name>: refused.
  5. Anything else is allowed.

Exit codes: 0 allows, 1 refuses, 2 means it could not decide. Callers treat any non-zero exit as a refusal. "Could not decide" covers:

  • bad input;
  • a missing list, services tree, or (with --tenant) tenants tree;
  • an unrecognised list line;
  • a wildcard grant;
  • a list without exactly one registry line;
  • a caller registry other than the list's.

The list

config/image-names.txt holds:

  • registry ghcr.io/mctlhq;
  • the reserved platform names;
  • reviewed grants for the services that use them today: admins and labs platform products, ovk mctl-academy, and team platform for the images built by hand from platform-gitops/images/;
  • shared openclaw + grant labs openclaw. Several teams have an openclaw service, which runs the fixed mctl-openclaw image. Only mctl-openclaw's upstream sync (as labs) builds openclaw.

Where it runs

  • deploy-service validate: runs --tenant, right after the gitops clone, for every action, with deploy-service's container_registry.
    • Validate also decides once whether this run builds, and that decision is both the --build flag and a new builds output. deploy-service's build-image step runs on that output alone, so the check and the build cannot disagree.
    • It never builds for update-config or without a dockerfile_repo.
    • Otherwise, the values the service will run decide:
      • on onboard, the template tpl-git-commit will render (same selection, __SERVICE_NAME__ substituted), so the openclaw template does not build;
      • on any other action, the existing values.yaml.
    • It builds only if those values run <registry>/<component_name>, or name no image.
    • Images are read by scripts/values-images.sh, which check-image-name.sh also uses. It reads repository: <image>, quoted or not, with an optional trailing comment. Any other form of the key (flow style, a quoted key, a block-scalar value, an alias) is "could not decide": validate fails the step, and the check exits 2. Block scalars, whether a key or a sequence item (- |) opens them, are text scoped as in YAML, and are skipped. An onboard image still holding a __PLACEHOLDER__ after __SERVICE_NAME__/__TEAM_NAME__ substitution also fails the step.
    • deploy-service's build-image when reads validate's new builds output, so wft-deploy-service and tpl-validate-tenant must be on the cluster together. Against an older tpl-validate-tenant, a run errors at parameter resolution and nothing is built or committed. Both are in the same Argo CD Application, so wait for the sync before submitting.
  • preview-deploy: a new image-name step (tpl-validate-tenant, an anonymous clone of the public repo, --tenant, --build when git_ref is set). Both the build and the deploy depend on it.
  • build-image.yaml: runs --build before any credential is fetched, against ${REGISTRY}/${GITHUB_REPOSITORY_OWNER}. A direct dispatch must also push exactly to <registry>/<component_name>. The policy is read from main, not from caller input.

What build-image.yaml skips

  • Release builds (workflow_call from release-deploy.yaml) are not checked there. Every other dispatcher in the organisation goes through this path, verified on each default branch: release-please.yml in mctl-academy, mctl-agent, mctl-agents, mctl-api, mctl-docs, mctl-portal, mctl-telegram, mctl-web, portfolio and seerrsense; release-deploy.yml in mctl-alice; deploy.yml in mctl-design; and preview-deploy.yml in mctl-telegram. A test runs the caller classification for a release-deploy ref. Their image names follow the release and can differ from the component name. github.workflow_ref, bound explicitly into the classifying step, tells the two cases apart: in a called run it names the caller's workflow.
  • A dispatch at a ref other than main runs that ref's copy of the workflow; the policy data is still read from main.
  • Both gaps are reachable only by holders of actions:write on this repository, and the workflow comment names them.
  • Direct dispatches no longer push :latest; release builds keep it. Nothing in this repository or in the cluster pulls ghcr.io/mctlhq/*:latest.

Other changes

  • wft-create-tenant refuses the tenant name platform.

Effect on today's services

  • All 30 existing services/<team>/<name> pass, and the test checks this on every run.
  • The direct build-image.yaml dispatchers found on the organisation's default branches still pass: mctl-openclaw's upstream sync, and the hand-built platform images.
  • A new service under a reserved name, or under a name another team already uses, needs a reviewed grant line first.
  • labs/mctl-telegram-preview runs ghcr.io/mctlhq/mctl-telegram, so a deploy-service deploy of it no longer dispatches a build of a name nothing runs.

Tests

tests/test_check_image_name.py is added to validate-manifests.yml. It covers:

  • Fixture cases for every branch and every fail-closed path: --tenant, --build, shared, the registry checks, and a missing tenants tree.
  • The real repository: every existing service passes, and every image the platform references outside services/ is reserved (11 today).
  • validate's build decision: extracted from the template and run against fixtures (14 cases, plus 2 that must fail the step). They include a deploy of an existing openclaw service with a defaulted service_template, a trailing comment, an unknown template and a worker-service onboard.
  • values-images.sh: a 34-case table. Its output matches a structural YAML parse on every existing service and template, and on about 4,900 generated documents (checked locally). The suite passes under macOS awk, busybox awk and mawk.
  • Wiring:
    • validate, preview-deploy and build-image each call the check, with the right flags, before anything is built or pushed;
    • deploy-service builds exactly on builds;
    • preview-deploy hands git_ref to image-name;
    • both Argo callers carry team_name and component_name from the build task into the build-image.yaml dispatch payload;
    • :latest is conditional;
    • platform is a reserved tenant name.

Mutation evidence: each of these makes the test fail:

  • The rule: removing the reserved check, the prefix check, the other-team directory check or the values check; letting a grant match any team; accepting unknown lines or wildcard grants; dropping the name-shape check, the --tenant check, the platform exception, the shared-build refusal, the registry equality or the tenants-tree precondition.
  • Validate's build decision: ignoring the existing service; letting the openclaw template build; not accepting a quoted repository; not deriving --build; dropping the builds output.
  • Wiring: unwiring the check from validate; running validate or image-name without --tenant; restoring the old when; dropping team_name from deploy-service's build task; dropping component_name from preview-deploy's dispatch payload; dropping preview-deploy's git_ref handover or its dependency.
  • build-image.yaml: pushing :latest unconditionally; dropping the image-name pin; classifying the caller from anything but the bound github.workflow_ref.
  • The reader and its callers: no comment stripping, no block-scalar tracking, a block scalar that never ends, a swallowed key dropped silently, quoted keys not detected, refusing on text that only mentions repository; validate not failing on unreadable values, or ignoring the template on onboard, or using the wrong worker path; the ownership scan skipping an unreadable values.yaml.

The test also passes under busybox sh (python:3.12-alpine). shellcheck is clean on the script. Compared with main, argo lint --offline reports no new findings on the changed templates. The shell-interpolation, shared-writes and yq-interpolation validators pass.

🤖 Generated with Claude Code

https://claude.ai/code/session_01D1kGUJMWeA8hyhQuxKuHd2

Tenant builds push, and tenant services run, ghcr.io/mctlhq/<component
name>, so the component name is the image name, and nothing tied it to
the team asking. check-image-name.sh decides from a reviewed list
(config/image-names.txt: reserved platform names, a mctl- prefix, and
explicit grants for the services that use them today) plus the
services/ tree: a name another team already has is not available.

It runs in deploy-service validation, in a new image-name step that
gates preview-deploy's build and deploy, and in build-image.yaml before
any credential is fetched, where a direct dispatch must also push to
exactly ghcr.io/mctlhq/<component_name>. Direct dispatches no longer
move :latest, which nothing pulls; release builds keep it. "platform",
the team the platform's own image grants use, is reserved as a tenant
name.

Claude-Session: https://claude.ai/code/session_01D1kGUJMWeA8hyhQuxKuHd2
Copilot AI balanced review requested due to automatic review settings October 4, 2026 16:21

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@claude

claude Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 10m 6s —— View job


Review complete — changes requested (0 P1, 2 P2, 4 P3)

  • Read the pinned diff (da3171b9...3b391a7e)
  • Review check-image-name.sh logic
  • Review image-names.txt coverage
  • Review Argo Workflows wiring (validate / preview-deploy / create-tenant)
  • Review build-image.yaml + CI wiring
  • Review test coverage
  • Post inline findings + verdict

The design is sound and the script is carefully fail-closed. What I verified by reading the tree: input grammar, unparsable-list and missing-list all exit 2; awk -v and the grep interpolation are safe behind the [a-z0-9-] grammar; both globs guard with -d/-f; the check precedes credential fetch and push in all three callers; every service uses the single image.repository form the script greps for (the split registry:+repository: form exists only in third-party charts, which base-service does not support), so no claim is missed today; and the :latest narrowing is safe — nothing pins tag: latest, and when tpl-validate-tenant resolves the tag to latest it is still pushed as the primary tag.

P2 — should fix before merge

1. team_name is caller-asserted, so the platform grants are reachable without being the platform · image-names.txt:40

grant platform vault-iac / zitadel-iac / grafana-iac / mc / mctl-agent covers images that run with Vault, Zitadel and Grafana admin credentials. The guard for that is reserving the tenant name in wft-create-tenant.yaml:150 — but that only blocks creating a tenant called platform, not passing team_name: platform to deploy-service/preview-deploy. tpl-validate-tenant's validate never checks that the team exists: its annotation claims "checks team exists in Git tenants/ directory", and tenants/ appears nowhere else in the file. Before this PR a spoofed team bought nothing; now it is the key to the whole model. Fix belongs in the two Argo callers (require platform-gitops/tenants/<team>/), not in the script — putting it in the script would break the hand-dispatched platform builds.

2. The new image_name pin binds dispatchers outside this repo, untested · build-image.yaml:149

direct == true is every workflow_dispatch. Per docs/runbooks/github-app-scope-audit.md:86-87, mctl-telegram/.github/workflows/preview-deploy.yml plus nine release-please.yml dispatch steps hold actions:write on mctl-gitops. Component≠image genuinely occurs here — services/labs/mctl-telegram-preview/values.yaml:17 runs ghcr.io/mctlhq/mctl-telegram. The test asserts the pin exists but cannot assert real callers satisfy it, and there is no warn-only period. Worth confirming each external dispatcher's (image_name, component_name) pair, or landing this half as ::warning:: first.

P3 — nits

Not verified

I could not execute anything in this environment — python3 tests/test_check_image_name.py, the script itself, yamllint, actionlint and argo lint all needed command approval. The pass/fail claims in the PR body are unverified here; I reviewed by reading. If you want them run from this job, add them to --allowedTools.
· branch fix/reserve-image-names

@github-actions

github-actions Bot commented Oct 4, 2026

Copy link
Copy Markdown

Antigravity (agy) review — informational

Model: gemini-3.8-flash-medium · commit 3b391a7.

Finding 1: Undefined workflow inputs break image name validation on direct dispatches

  • Severity: P1
  • File: .github/workflows/build-image.yaml (approx. line 143)
  • Anchor: + INPUT_COMPONENT_NAME: ${{ inputs.component_name }}
  • Failure Scenario: The new Check image name step sets environment variables INPUT_TEAM_NAME and INPUT_COMPONENT_NAME from ${{ inputs.team_name }} and ${{ inputs.component_name }} and validates that INPUT_IMAGE_NAME matches ${REGISTRY}/${GITHUB_REPOSITORY_OWNER}/${INPUT_COMPONENT_NAME}. However, neither team_name nor component_name is declared in the workflow's on.workflow_dispatch.inputs (or workflow_call.inputs) block.
    When dispatched via the GitHub API, passing undeclared inputs will either cause the API call to fail with HTTP 422 (Unprocessable Entity) or cause ${{ inputs.component_name }} and ${{ inputs.team_name }} to evaluate to empty strings. If empty, expected evaluates to ${REGISTRY}/${GITHUB_REPOSITORY_OWNER}/, causing [ "$INPUT_IMAGE_NAME" != "$expected" ] to fail and abort every direct dispatch. Furthermore, triggering the workflow manually via GitHub UI does not present input fields for team_name or component_name.
  • Proposed Fix: Declare team_name and component_name as required string inputs under inputs: in .github/workflows/build-image.yaml.

Finding 2: Malformed Argo DAG dependency prevents deployment when image build is skipped

  • Severity: P2
  • File: platform-gitops/argo-workflows/cluster-templates/wft-preview-deploy.yaml (approx. line 100)
  • Anchor: + dependencies: [image-name, build-image]
  • Failure Scenario: build-image has a conditional execution check: when: "\"{{workflow.parameters.git_ref}}\" != \"\"". When git_ref is omitted (e.g. previewing an existing image_tag), build-image evaluates to Skipped. In Argo Workflows, specifying dependencies: [image-name, build-image] requires all listed tasks to succeed (.Succeeded). Because build-image was skipped, Argo marks deploy-preview as omitted/skipped as well, preventing preview deployments from completing when an existing image tag is used.
  • Proposed Fix: Use the depends expression to allow deploy-preview to run if build-image succeeds or is skipped:
    depends: "image-name && (build-image.Succeeded || build-image.Skipped)"

Finding 3: Unreserved admins tenant name permits privilege escalation over platform services

  • Severity: P2
  • File: platform-gitops/argo-workflows/cluster-templates/wft-create-tenant.yaml (approx. line 150)
  • Anchor: + for reserved in argocd argo-workflows argo-events kube-system kube-public kube-node-lease default cert-manager traefik vault external-secrets monitoring platform; do
  • Failure Scenario: wft-create-tenant.yaml added platform to the list of reserved namespace/tenant names specifically because image-names.txt grants platform image rights to team platform. However, image-names.txt also grants multiple core administrative services (mctl-agents-worker, mctl-agents-worker-exec, mctl-agents-worker-implement, mctl-design, mctl-docs, mctl-web) to team admins. Because admins is not included in the reserved list, onboarding a tenant named admins would allow that tenant to inherit authorization to build into and run platform admin image names.
  • Proposed Fix: Add admins to the list of reserved tenant names in wft-create-tenant.yaml.

Finding 4: Unauthenticated clone in image-name template will fail on private repository

  • Severity: P2
  • File: platform-gitops/argo-workflows/cluster-templates/tpl-validate-tenant.yaml (approx. line 201)
  • Anchor: + git clone --depth=1 "https://github.com/${GITOPS_ORG}/${GITOPS_REPO}.git" /tmp/mctl-gitops
  • Failure Scenario: The image-name template executes git clone --depth=1 "https://github.com/${GITOPS_ORG}/${GITOPS_REPO}.git" /tmp/mctl-gitops anonymously without mounting credentials, assuming mctl-gitops is public. However, mctl-gitops is a private GitOps repository (as demonstrated by the neighboring validate template requiring mctl-gitops-deploy-key to clone via SSH). An unauthenticated HTTPS clone against a private repository will fail (fatal: could not read Username for 'https://github.com': No such device or address), causing the image-name validation step in wft-preview-deploy to fail on every run.
  • Proposed Fix: Mount the mctl-gitops-deploy-key secret volume and clone via SSH, or inject an authenticated GitHub token via git clone "https://x-access-token:${GITHUB_TOKEN}@github.com/...".


# Platform images built from platform-gitops/images/ and dispatched by hand
# with team "platform" (not a tenant; wft-create-tenant refuses the name).
grant platform grafana-iac

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — team_name is a caller-asserted string in the Argo path, so the platform grants are reachable without being the platform.

These five lines give team platform the right to build and push grafana-iac, mc, mctl-agent, vault-iac and zitadel-iac — images that run with Vault/Zitadel/Grafana admin credentials. The guard added for that is reserving the tenant name in wft-create-tenant.yaml:150, but that only blocks creating a tenant called platform; it does not block passing team_name: platform to deploy-service or preview-deploy.

tpl-validate-tenant's validate never checks that the team exists. Its own annotation claims it does ("checks team exists in Git tenants/ directory", line 8), but grepping the template for tenants/ finds only that annotation — steps 1→8 validate the name grammar, dockerfile_repo, and the service directory, never platform-gitops/tenants/<team>/. So action: onboard with team_name: platform passes validation, check-image-name.sh matches grant platform vault-iac, and the build is dispatched.

Before this PR a spoofed team_name bought nothing (no name was tied to a team). Now it is the key to the whole ownership model, and platform is the highest-value value it can take.

Suggested fix, and it is small: have the Argo-side callers refuse a team with no platform-gitops/tenants/<team>/ directory — in validate (after the clone, before 3b) and in the new image-name template. platform is not in tenants/ (admins erpact karabu labs mctl ovk), so that makes the platform grants unreachable from the tenant-facing workflows while leaving build-image.yaml's hand-dispatched platform builds working. Putting the check inside check-image-name.sh instead would break those platform builds, so it belongs in the callers.

Fix this →

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in be70817, a variation on your suggestion. The tenant check lives in the script behind a --tenant flag, so it is tested in one place, and only the two Argo callers pass that flag.

  • With --tenant, the team must have platform-gitops/tenants/<team>/, and platform is refused outright. The second rule holds even if a tenants/platform directory ever appeared; there is a fixture for that.
  • tpl-validate-tenant's validate (step 3b) and the new image-name template both run with --tenant. build-image.yaml does not, so the hand-dispatched platform builds keep working. The test asserts both callers pass the flag, and mutations dropping it are caught.
  • wft-create-tenant now reserves admins as well as platform. That workflow continues when the tenant directory already exists, so admins must not be creatable again.

Binding the team to the workflow namespace was considered and rejected. Admins submit tenant operations from the default namespace, so namespace == team would break them.

run: |
set -euo pipefail
expected="${REGISTRY}/${GITHUB_REPOSITORY_OWNER}/${INPUT_COMPONENT_NAME}"
if [ "$INPUT_IMAGE_NAME" != "$expected" ]; then

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — this pin is newly mandatory for dispatchers that live outside this repo, and nothing here verifies they satisfy it.

direct == true covers every workflow_dispatch, not just the two Argo templates. Per docs/runbooks/github-app-scope-audit.md:86-87, mctl-telegram/.github/workflows/preview-deploy.yml and the release-please.yml dispatch steps in mctl-api, mctl-agent, mctl-agents, mctl-portal, mctl-docs, mctl-academy, mctl-telegram, mctl-design and pfeifenpatenschaft-backend all hold actions:write on mctl-gitops. Any of them that dispatches build-image.yaml (rather than release-deploy.yaml) now has to pass image_name == ghcr.io/mctlhq/<component_name> exactly.

That pairing is not universal in this platform today: platform-gitops/services/labs/mctl-telegram-preview/values.yaml:17 runs ghcr.io/mctlhq/mctl-telegram, i.e. the component and the image genuinely differ for the preview service that the external preview-deploy.yml feeds. tests/test_check_image_name.py asserts the pin exists (line 183) but cannot assert that real callers match it, and grant labs mctl-telegram-preview in image-names.txt:57 is there only to satisfy "every existing service passes" — it does not tell us which component_name that dispatcher sends.

So the failure mode is: merge lands, the next mctl-telegram preview build fails at this step with image_name must be ghcr.io/mctlhq/mctl-telegram-preview, and there is no warn-only period to catch it. Worth confirming each external dispatcher's (image_name, component_name) pair before merge — or landing this half as a warning first (echo "::warning::" and no exit 1) and flipping it once the logs are clean.

The Argo-side pair is fine: wft-deploy-service.yaml:144 builds {{container_registry}}/{{component_name}} and wft-preview-deploy.yaml:166 builds ghcr.io/{GITOPS_ORG}/{component_name}, both matching.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified on every mctlhq repository's default branch.

Only two kinds of caller dispatch build-image.yaml directly:

Dispatcher team component image_name Passes
mctl-openclaw upstream-sync-release.yml labs openclaw ghcr.io/mctlhq/openclaw yes (grant labs openclaw)
hand dispatch, platform-gitops/images/* platform grafana-iac, mc, vault-iac, zitadel-iac, mctl-agent ghcr.io/mctlhq/ yes (grant platform ...)

Every other dispatcher calls release-deploy.yaml, which reaches build-image.yaml as a workflow_call. The pin does not apply there:

  • release-please.yml: mctl-api, mctl-agent, mctl-agents, mctl-portal, mctl-docs, mctl-academy, mctl-telegram, mctl-web, portfolio, seerrsense;
  • deploy.yml: mctl-design;
  • release-deploy.yml: mctl-alice, pfeifenpatenschaft-backend;
  • preview-deploy.yml: mctl-telegram. This one also covers the mctl-telegram-preview → ghcr.io/mctlhq/mctl-telegram case you mention.

The test now pins both direct dispatchers: each of their (team, component) pairs must pass in --build mode against the real tree.

Comment thread .github/workflows/build-image.yaml Outdated
# images are named by their release and may differ from the component
# name, and release-deploy is dispatched by the release bots and
# operators, never by a tenant workflow. GITHUB_WORKFLOW_REF names the
# caller's workflow in a called run, so it tells the two apart.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — the comment calls this "the second layer, at the push", but it is weaker than that, and the gap is worth naming in the comment rather than leaving implied.

Two ways past it, both available to a holder of the credential that already dispatches builds (actions:write on mctl-gitops, which is what Argo's Vault PAT uses at wft-deploy-service.yaml:498):

  1. Dispatch release-deploy.yaml instead. It is workflow_dispatch with a free-text image_name, it calls build-image.yaml via workflow_call, so direct=false and the check is skipped entirely — and it then bumps the gitops values.yaml too.
  2. Dispatch build-image.yaml itself at a non-main ref. The case pattern accepts any ref after @, and the workflow definition comes from that ref, so a chosen ref means a chosen check. (Pinning the policy to main closes the data half of this but not the code half.)

Neither is tenant-reachable — tenants cannot choose which mctl-gitops workflow Argo dispatches — so the Argo-side check at tpl-validate-tenant.yaml:122 remains the real boundary and this PR is still a net improvement. But if the intent is defence in depth at the push, gating release-deploy.yaml's image_name against reserved/reserved-prefix for the given team (without requiring it to equal component_name, since release names legitimately differ) would cost a few lines and close case 1.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both gaps are now spelled out in the step's comment (be70817):

  • release-deploy.yaml takes a free-text image_name and arrives here as an unchecked workflow_call.
  • A dispatch at a ref other than main runs that ref's copy of the workflow. The policy data is read from main either way.

The comment also says that Argo's validation is the tenant boundary, and that this step is a backstop for holders of actions:write. Gating release-deploy's image_name is tracked as a follow-up rather than done here.

exit 1
fi
echo "✅ image name ${PARAM_COMPONENT_NAME} is available to ${PARAM_TEAM_NAME}"
resources:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — requests without limits, unlike the other pods in this directory.

Every other cluster template that declares resources pairs the two: cronworkflow-labs-claude-remote-restart.yaml:59-64, cwft-mctl-agents-approve.yaml:167-168, cwft-mctl-agents-implement.yaml:360-361, cwft-mctl-agents-investigate.yaml:338-339. A git clone --depth=1 of mctl-gitops is not free either — the repo is large enough that a burstable-with-no-ceiling pod is the one thing standing between a slow clone and node pressure.

        resources:
          requests:
            memory: 32Mi
            cpu: 10m
          limits:
            memory: 128Mi
            cpu: 200m

Fix this →

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in be70817. Both the new image-name pod and the existing validate pod now have limits: {memory: 128Mi, cpu: 200m} next to their requests. validate had no resources block at all before. The test asserts limits on both, and a mutation removing them is caught.

# A service may run an image under a name other than its own directory
# (e.g. a preview of another service): those names are taken as well.
for values in "$SERVICES"/*/*/values.yaml; do
[ -f "$values" ] || continue

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — mctlhq is hardcoded here while every caller derives the org.

build-image.yaml builds expected from ${GITHUB_REPOSITORY_OWNER}, wft-preview-deploy.yaml:166 from GITOPS_ORG, wft-deploy-service.yaml:144 from the container_registry parameter. Only this grep assumes the string. The two cannot disagree today, but if they ever do, this loop silently stops claiming any name — it would find no match and fall through to exit 0, which is the fail-open direction, unlike every other failure path in the script.

Taking the registry prefix as a fourth argument (or reading it from the list as a registry line) keeps the fail-closed property uniform. Same for the comment on line 4.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in be70817. The registry is now a required argument: check-image-name.sh [--tenant] [--build] <checkout> <registry> <team> <name>.

  • It must equal the list's single registry ghcr.io/mctlhq line, and the values scan uses it, so nothing in the script hard-codes the organisation. A mismatch, a missing registry line or a second registry line all exit 2.
  • Each caller passes its own derivation:
    • validate passes deploy-service's container_registry, newly handed to it as a bound env value;
    • image-name passes ghcr.io/${GITOPS_ORG};
    • build-image.yaml passes ${REGISTRY}/${GITHUB_REPOSITORY_OWNER}.

So a caller deriving a different organisation is refused instead of failing open.

# openclaw), never an image named after the component, and their build input is pinned
# to mctlhq/mctl-openclaw by mctl-api. The name is shared by every team that
# deploys one.
grant * openclaw

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — the wildcard grant short-circuits rules 2 and 3, which is more than the comment above it needs.

Because rule 1 wins first, grant * openclaw means any team may build and push ghcr.io/mctlhq/openclaw from any dockerfile_repo, and the "another team already has services/<other>/openclaw" check never runs. That is harmless today — the openclaw services under labs, ovk and admins all run ghcr.io/mctlhq/mctl-openclaw, covered by reserved-prefix mctl-, and nothing in the repo or the cluster references ghcr.io/mctlhq/openclaw at all (the reference scan in tests/test_check_image_name.py would have failed otherwise, since openclaw is granted rather than reserved).

So the only thing the wildcard actually needs to unlock is rule 3 — letting several teams each own a services/<team>/openclaw directory. Worth a line in the comment saying that ghcr.io/mctlhq/openclaw itself is deliberately an unused name, so the day something is deployed under it the wildcard gets revisited rather than silently being world-writable.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Narrowed in be70817. Wildcard grants are now rejected by the parser (exit 2).

openclaw is a new shared openclaw line:

  • any team may run a service of that name, which lifts the cross-team rule only;
  • only a granted team may build into it. That is grant labs openclaw, for mctl-openclaw's upstream-sync dispatch, the only builder.

Build-time callers pass --build. deploy-service also no longer builds for the openclaw template: those services run the fixed mctl-openclaw image, so the build pushed an image nothing runs. The comment in image-names.txt records all of this, including that nothing in this repository runs the openclaw image.

for reserved in argocd argo-workflows argo-events kube-system kube-public kube-node-lease default cert-manager traefik vault external-secrets monitoring; do
# platform: the team name image-names.txt grants the platform's own
# images to; a tenant of that name would inherit those grants.
for reserved in argocd argo-workflows argo-events kube-system kube-public kube-node-lease default cert-manager traefik vault external-secrets monitoring platform; do

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — this new reservation is the only behaviour change in the PR with no test behind it.

tests/test_check_image_name.py asserts the wiring for validate, preview-deploy and build-image.yaml, and the PR body lists mutation evidence for eleven other changes, but deleting platform from this list leaves the suite green. Given the grants at image-names.txt:40-44 are the reason it is here, it is the line most worth pinning:

ct = (REPO / "platform-gitops/argo-workflows/cluster-templates/wft-create-tenant.yaml").read_text()
reserved_line = [ln for ln in ct.splitlines() if "for reserved in" in ln][0]
check("platform" in reserved_line.split(),
      "wft-create-tenant must reserve the tenant name 'platform' (image-names.txt grants to it)")

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in be70817. The test parses the for reserved in ... line of wft-create-tenant.yaml and requires both platform and admins. A mutation removing admins is caught.

exit 1
fi

for dir in "$SERVICES"/*/"$NAME"; do

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — rules 3 and 4 are a check-then-act race: two teams onboarding the same unclaimed name concurrently both pass.

Neither the directory scan nor the values scan sees a name that is mid-onboard. Team A and team B both submit deploy-service with action: onboard and component_name: shiny, both clone, both find no services/*/shiny, both reach rule 4, both dispatch a build of their own dockerfile_repo into ghcr.io/mctlhq/shiny, and the gitops commits do not conflict because they land in different team directories. Afterwards both services run whichever build pushed last, and the next check refuses whoever asks second — which is a confusing place to discover it.

Low likelihood and not a regression (today nothing claims names at all), so not a merge blocker. If you want it closed, the cheap version is tpl-git-commit's mutex: re-run the check inside the commit step, where the repository state being read is the state being written.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed it's a race. I've noted it in a comment at step 3b of validate rather than fixed it here.

The suggested re-check inside tpl-git-commit would come after the build has already pushed, so it would only stop the second gitops record, not the second push. The real fix is to claim the name and check it in one commit, before the build. That needs reordering deploy-service, which is out of scope for this PR.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Has P1/P2 findings, changes requested: 0 P1, 2 P2, 4 P3.

P2-1 (image-names.txt:40) — team_name is caller-asserted in the Argo path. tpl-validate-tenant's validate never checks that the team exists (its own annotation says it does; no code does), so team_name: platform reaches the grant platform vault-iac|zitadel-iac|grafana-iac|mc|mctl-agent lines. Reserving the tenant name in wft-create-tenant blocks creating such a tenant, not passing the string. Fix: require platform-gitops/tenants/<team>/ in the two Argo callers (not in the script, or the hand-dispatched platform builds break).

P2-2 (build-image.yaml:149) — the new image_name == ghcr.io/<owner>/<component_name> pin binds every workflow_dispatch, including the out-of-repo dispatchers listed in docs/runbooks/github-app-scope-audit.md:86-87. Component≠image does occur here (services/labs/mctl-telegram-preview runs ghcr.io/mctlhq/mctl-telegram), and no test can cover external callers. Confirm each dispatcher's pair, or land this half warn-only first.

The core design is sound and the script is carefully fail-closed: input grammar, unparsable-list, and missing-list all exit 2, -v/grep interpolation is safe behind the [a-z0-9-] grammar, globs guard with -d/-f, the check precedes credential fetch and push in all three callers, and the :latest narrowing is safe (nothing pins tag: latest, and a resolved latest tag is still pushed as the primary tag). Verified every service uses the single image.repository form the script greps for, so no claim is missed today. I could not execute the test suite or linters in this environment (command approval), so the pass/fail claims in the PR body are unverified here.

P3s: comment overstates the GHA layer as a second enforcement layer (release-deploy's workflow_call and a caller-chosen ref both skip it); requests without limits on the new image-name pod, against this directory's convention; mctlhq hardcoded in the values grep while callers derive the org, and that path fails open; grant * openclaw short-circuits rules 2 and 3; the new platform tenant reservation has no test; onboard is check-then-act.

Tenant workflows now run the check with --tenant: the team must be an
existing tenant (tenants/<team>/) and never "platform", so the grants
for hand-dispatched platform images are unreachable from deploy-service
and preview-deploy. "admins" is reserved in create-tenant as well.

The registry is an argument and must equal the list's registry line,
so a caller deriving another organisation is refused instead of being
checked against names it does not use. The wildcard grant is gone:
openclaw is a shared service name whose image only labs may build, and
deploy-service no longer builds for the openclaw template, whose
services run the fixed mctl-openclaw image. Build-time callers pass
--build, so a shared name can be run but not pushed to by any team.

Also: limits on the check pods, the push-time step names its known
gaps, and the onboard race is noted where the check runs.

Claude-Session: https://claude.ai/code/session_01D1kGUJMWeA8hyhQuxKuHd2
@claude

claude Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 3s —— View job


I'll analyze this and get back to you.

@mashkovd

mashkovd commented Oct 4, 2026 •

Copy link
Copy Markdown
Member Author

Replies to the agy review (3b391a7) and to the Claude review body. Fixes are in be70817e.

agy 1 (P1), undeclared team_name/component_name: refuted. Both are declared inputs of build-image.yaml:

  • workflow_dispatch: team_name and component_name are required string inputs (lines 28-35 on 3b391a7);
  • workflow_call: both are declared too.

Real direct dispatches already send them. The latest platform image run, 37212669451 on 2026-10-04, logs INPUT_TEAM_NAME: platform and INPUT_COMPONENT_NAME: grafana-iac. Argo's wft-deploy-service.yaml and wft-preview-deploy.yaml pass both in their dispatch payloads, as does mctl-openclaw's upstream-sync workflow.

agy 2 (P2), preview DAG blocked when build-image is skipped: refuted. The finding reads the depends semantics into a legacy dependencies list, which treats Skipped as satisfied. Argo's enhanced-depends documentation spells out the equivalence: dependencies: [A] is depends: "A.Succeeded || A.Skipped || A.Daemoned". The existing-image preview path has always relied on this, because deploy-preview already depended on the conditional build-image before this PR. A failed image-name still stops the deploy. I added a comment saying so at the task.

agy 3 (P2), admins claimable: fixed.

  • wft-create-tenant now reserves admins as well as platform. That workflow continues when the tenant directory already exists, so re-creating admins must be refused.
  • Asserting team_name on the Argo path is now bound: --tenant requires platform-gitops/tenants/<team>/ and refuses platform (see the thread on image-names.txt:40).
  • Acting as admins still requires mctl-api/portal membership of that tenant, which is the existing authorization boundary.

agy 4 (P2), anonymous clone of a private repo: refuted. mctlhq/mctl-gitops is public (gh api repos/mctlhq/mctl-gitops --jq .visibility returns public), so the anonymous HTTPS clone works. validate predates this PR and happens to clone over SSH with the deploy key; reading a public repository needs no credential.

Claude review body: every P2 and P3 is answered on its own thread.

  • P2-1, team binding: fixed (--tenant).
  • P2-2, external dispatchers: verified on every repository's default branch, with a table on the thread. Only mctl-openclaw's upstream sync and the hand-dispatched platform images call build-image.yaml directly. Both pass, and both are now pinned in the test.
  • P3s:
    • the gaps in the push-time check are named in its comment;
    • resource limits are added;
    • the registry is derived by each caller and must match the list, so it fails closed;
    • the wildcard is replaced by shared openclaw plus grant labs openclaw, and wildcards are rejected;
    • the platform/admins reservation is tested;
    • the onboard race is noted at the check.

Verification on be70817e:

  • tests/test_check_image_name.py passes: 33 fixture cases, all 30 existing services (in --tenant mode where the team is a tenant), the 11 referenced platform images, and both direct dispatchers.
  • 26 of 26 mutations are caught, each run against the test suite: every branch of the script, every caller's wiring, the --build condition kept in step with deploy-service, the create-tenant reservations, and the limits.
  • The script also runs under busybox in alpine/git:2.43.0.
  • yamllint (CI config) reports 0 errors.
  • Compared with main, actionlint, argo lint --offline and kubeconform report no new findings. The shell-interpolation, yq-interpolation and tenant-shared-writes validators pass.

@github-actions

github-actions Bot commented Oct 4, 2026

Copy link
Copy Markdown

Antigravity (agy) review — informational

Model: gemini-3.8-flash-medium · commit be70817.

Finding 1: Undeclared team_name and component_name inputs in build-image.yaml

  • Severity: P1

  • File: .github/workflows/build-image.yaml (approx. line 150)

  • Anchor: + INPUT_TEAM_NAME: ${{ inputs.team_name }}

  • Concrete Failure Scenario:
    The Check image name step for direct dispatches relies on ${{ inputs.team_name }} and ${{ inputs.component_name }}. However, neither team_name nor component_name was added to on.workflow_dispatch.inputs (or on.workflow_call.inputs) under lines 6–26 of .github/workflows/build-image.yaml.

    In GitHub Actions:

    1. Any workflow dispatch API call supplying undeclared inputs will be rejected by GitHub's API (HTTP 422) or have the undeclared inputs discarded.
    2. Manual dispatches from the GitHub UI cannot prompt for or accept these fields.
    3. Inside the runner, ${{ inputs.team_name }} and ${{ inputs.component_name }} evaluate to empty strings.

    Consequently, expected="${REGISTRY}/${GITHUB_REPOSITORY_OWNER}/${INPUT_COMPONENT_NAME}" evaluates to ghcr.io/mctlhq/. The comparison if [ "$INPUT_IMAGE_NAME" != "$expected" ] will evaluate to true and fail every direct dispatch with exit code 1.

  • Proposed Fix:
    Declare team_name and component_name under on.workflow_dispatch.inputs in .github/workflows/build-image.yaml:

          team_name:
            description: "Requesting team name"
            required: false
            type: string
          component_name:
            description: "Component name"
            required: false
            type: string

Finding 2: wft-deploy-service.yaml does not pass team_name and component_name to trigger-github-build

  • Severity: P1

  • File: platform-gitops/argo-workflows/cluster-templates/wft-deploy-service.yaml (approx. line 135)

  • Anchor: + when: "\"{{workflow.parameters.action}}\" != \"update-config\" && \"{{workflow.parameters.dockerfile_repo}}\" != \"\" && \"{{workflow.parameters.service_template}}\" != \"openclaw\""

  • Concrete Failure Scenario:
    While wft-preview-deploy.yaml was updated to pass team_name and component_name parameters to its build-image step, wft-deploy-service.yaml only passes dockerfile_repo, git_tag, image_name, and image_tag.

    When deploy-service initiates a direct dispatch to build-image.yaml, team_name and component_name are not provided. The Check image name step in build-image.yaml receives an empty string for INPUT_COMPONENT_NAME, computes expected="ghcr.io/mctlhq/", and fails on if [ "$INPUT_IMAGE_NAME" != "$expected" ]. This breaks all Docker image builds triggered by wft-deploy-service.

  • Proposed Fix:
    Pass team_name and component_name under arguments.parameters to trigger-github-build in platform-gitops/argo-workflows/cluster-templates/wft-deploy-service.yaml:

                  - name: team_name
                    value: "{{workflow.parameters.team_name}}"
                  - name: component_name
                    value: "{{workflow.parameters.component_name}}"

@mashkovd

mashkovd commented Oct 4, 2026

Copy link
Copy Markdown
Member Author

@claude review

The automatic run for be70817 (run 37218071930) completed its review step in about 30 seconds (the run for 3b391a7 took 11 minutes), posted only the placeholder, and submitted no review bound to be70817. The PR therefore still carries the CHANGES_REQUESTED verdict from 3b391a7. Please review be70817, including the replies to the earlier threads.

@claude

claude Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 8m 23s —— View job


Review complete — be70817e · changes requested (0 P1, 1 P2, 3 P3)

  • Read the pinned diff (da3171b9...be70817e)
  • Re-check the two P2s from the previous review against the replies
  • Review the new --tenant / --build / registry logic in check-image-name.sh
  • Review the Argo + GHA wiring changes
  • Review image-names.txt and the test suite
  • Try to execute the test suite — blocked, see below
  • Post findings + submit the verdict bound to be70817e

The earlier CHANGES_REQUESTED is superseded: a review is now recorded against be70817e (#pullrequestreview-5407323173).

Both prior P2s are resolved, and resolved well. --tenant (check-image-name.sh:84-91) requires platform-gitops/tenants/<team>/ and refuses platform unconditionally; both Argo callers pass it and build-image.yaml deliberately does not, so the hand-dispatched platform builds keep working. Putting it in the script behind a flag rather than duplicating it in two templates is the better version of what I suggested. The registry becoming a required argument checked against a single registry line closes the last fail-open path — a caller deriving a different org is now refused rather than silently matching nothing. Wildcards rejected, shared openclaw + grant labs openclaw splits run from build, limits on both pods, and the build-image.yaml comment now names both of its own gaps.

All four agy findings are wrong or already handled, and I checked each against the tree rather than taking the reply at face value:

  • agy 1: team_name/component_name are declared, under both workflow_dispatch (build-image.yaml:28-35) and workflow_call (58-63).
  • agy 2: wft-deploy-service.yaml:150-153 passes both to trigger-github-build, and they reach the dispatch payload at line 538. The DAG claim is also wrong — legacy dependencies is .Succeeded || .Skipped || .Daemoned, and deploy-preview already depended on the conditional build-image before this PR.
  • agy 4: the anonymous clone is fine; validate's deploy key is for a write path, not a read one.
  • agy 3 was acted on — and that is the one change I'd push back on.

P2 — should fix before merge

Reserving admins in wft-create-tenant · wft-create-tenant.yaml:153

platform belongs on that list — there is no tenants/platform/, so blocking creation is exactly what makes the grant platform … lines unreachable. admins is the inverse case and the entry is a net loss:

  • Gains nothing. tenants/admins/ already exists, so --tenant accepts team_name: admins today without anyone creating anything, and wft-delete-tenant-safe.yaml:99 already refuses to free the name. The escalation agy described cannot happen; the boundary is mctl-api membership, as you said.
  • Costs the re-provision path. wft-create-tenant.yaml:461-463 is explicitly re-runnable ("already exists — skipping git commit, continuing provisioning"), which is how the Vault policy, ESO role, namespace and ArgoCD RBAC fragment get re-created after a restore. validate-tenant-name is step 1, so it now fails first — for the platform's own tenant, and only that one.
  • Inconsistent with its own comment. labs holds ten grants and ovk one; neither is reserved.

P3

Not verified

python3 tests/test_check_image_name.py, the script itself, yamllint, actionlint and argo lint all needed command approval in this job, so the pass/fail and mutation claims in the PR body are unverified here — I reviewed by reading and traced the fixture cases by hand. One thing I did cross-check: references/k8s.md:146,219 confirms ghcr.io/mctlhq/mctl-openclaw is published by mctl-openclaw's own Docker Release workflow on a v* tag, never through build-image.yaml, and that ghcr.io/mctlhq/openclaw is the legacy path — so the shared + grant labs pair is right and reserved-prefix mctl- catches nothing in that pipeline.
· branch fix/reserve-image-names

# must not be created again by someone else (this workflow continues
# on an existing tenant directory); wft-delete-tenant-safe likewise
# refuses admins.
for reserved in argocd argo-workflows argo-events kube-system kube-public kube-node-lease default cert-manager traefik vault external-secrets monitoring platform admins; do

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 — reserving admins removes the documented idempotent re-provisioning path for a tenant that already exists, and the thing it is meant to stop is already impossible.

platform is right: there is no platform-gitops/tenants/platform/, so refusing to create one is what keeps the grant platform vault-iac|zitadel-iac|grafana-iac|mc|mctl-agent lines unreachable from --tenant callers. admins is the opposite case.

  1. It gains nothing. platform-gitops/tenants/admins/ already exists, so check-image-name.sh:88 accepts team_name: admins today without anyone creating anything. Reaching grant admins mctl-web never required creating the tenant; it requires being able to submit a deploy-service workflow as team admins, which is mctl-api/portal membership — the boundary you correctly identify in your reply. And wft-delete-tenant-safe.yaml:99 already refuses to delete admins, so the name cannot be freed and re-taken either.

  2. It costs the re-provision path. This workflow is deliberately re-runnable: wft-create-tenant.yaml:461-463 logs Tenant directory already exists … skipping git commit, continuing provisioning and carries on to re-create the Vault policy, the ESO role, the namespace and the ArgoCD RBAC fragment. That is the recovery path after a Vault restore or a cluster rebuild. validate-tenant-name is step 1, so the new entry fails the workflow before any of it runs — for the platform's own tenant, and only for that one.

  3. It is inconsistent with its own comment. The comment says these are "the teams image-names.txt grants the platform's own images to", but labs holds ten grants (image-names.txt:64-73) and ovk one, and neither is reserved. If existing-tenant-with-grants were the criterion, three names belong on the list; if it is not, admins does not.

Suggest dropping admins and keeping platform, or — if you want the belt — moving the guard to "refuse when the tenant directory already exists" so it is about re-creation generally rather than one hard-coded name. Either way tests/test_check_image_name.py:225 needs the matching change.

Fix this →

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 30feeab: admins is dropped from the reserved list and platform stays. The comment above the loop now gives the reason for platform alone: it is never a tenant, which is what keeps the grant platform … lines unreachable for --tenant callers. The test now requires only platform.

Separately, the general re-creation case you mention is handled in #1650. There, create-tenant fails in validate on any existing tenant unless reprovision=true is passed, so re-provisioning admins after a restore remains a deliberate re-run.

check("image-name" in tasks["build-image"].get("dependencies", []),
"preview-deploy's build must depend on the image-name check")
check("image-name" in tasks["deploy-preview"].get("dependencies", []),
"preview-deploy's deploy must depend on the image-name check")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — the one new wiring link with no test: preview-deploy passing git_ref to image-name.

The suite pins the templateRef and both dependencies, and line 202 pins that the image-name template reads PARAM_GIT_REF. Nothing pins that wft-preview-deploy.yaml:72-73 actually hands it over. Delete those two lines and every preview build silently drops to the run-only check — shared openclaw becomes buildable by any team from preview-deploy again, which is exactly the hole --build was added to close. That mutation is in the same class as the eleven the PR body lists, and it is the only one left green.

pparams = {p["name"]: p["value"] for p in tasks["image-name"]["arguments"]["parameters"]}
check(pparams.get("git_ref") == "{{workflow.parameters.git_ref}}",
      "preview-deploy must tell image-name whether it will build")

Fix this →

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 30feeab. tests/test_check_image_name.py now asserts that preview-deploy's image-name task passes git_ref={{workflow.parameters.git_ref}}. Removing those two lines fails the suite (verified as mutation N2).

- name: build-image
dependencies: [validate]
when: "\"{{workflow.parameters.action}}\" != \"update-config\" && \"{{workflow.parameters.dockerfile_repo}}\" != \"\""
when: "\"{{workflow.parameters.action}}\" != \"update-config\" && \"{{workflow.parameters.dockerfile_repo}}\" != \"\" && \"{{workflow.parameters.service_template}}\" != \"openclaw\""

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — the new service_template != "openclaw" clause gates on an onboard-time parameter, and a deploy that omits it is now refused where it used to pass.

service_template defaults to "default" (line 76) and describes what template to write at onboard; it says nothing about the service that already exists. tpl-git-commit.yaml:360 only consults it under ACTION = onboard. So for action: deploy on an existing openclaw service, a caller that supplies dockerfile_repo but lets service_template default gets BUILD_FLAG=--build in validate — and shared openclaw then refuses admins and ovk outright (exit 1, whole workflow fails). Under grant * openclaw on main that same call passes.

Probably unreachable: references/deploy.md:172 says dockerfile_repo is not required and no build is triggered for a non-default template, and references/k8s.md:144-148 documents the openclaw image bump as a hand-written PR on this repo, not a deploy-service call. But the comment this commit replaces said "their build input is pinned to mctlhq/mctl-openclaw by mctl-api", which reads as mctl-api does send dockerfile_repo for openclaw — worth confirming it also sends service_template on deploy, not only on onboard.

The parameter that actually describes the service is services/<team>/<name>/values.yaml's image.repository: when it is not <registry>/<component_name>, building that name is pointless regardless of which template produced it. Keying the skip off the checkout validate has already cloned would make the rule true for every action, and would also cover labs/mctl-telegram-preview (runs ghcr.io/mctlhq/mctl-telegram), which still dispatches a build of a name nothing runs.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 30feeab. Validate now makes the build decision once and bases it on the existing service, not on the onboard parameter:

  • never for update-config, or without a dockerfile_repo;
  • on onboard, by the template (openclaw runs mctl-openclaw);
  • on any other action, by services/<team>/<name>/values.yaml. It builds only when a repository: line is <registry>/<component_name> (quoted or not), or when no image is named at all (the old behaviour; deploy without a values.yaml still fails at step 6).

The result is written once and used twice. It sets the --build flag for the check, and it is a new builds output. deploy-service's build-image now runs on "{{tasks.validate.outputs.parameters.builds}}" == "true" alone, so the check and the build cannot disagree.

As you noted, labs/mctl-telegram-preview (which runs ghcr.io/mctlhq/mctl-telegram) no longer dispatches a build of a name nothing runs.

The test extracts the block from the template and runs it against a fixture, with 10 cases, including deploy of an existing openclaw service with a defaulted service_template (now: no build, checked as a run). It also passes under busybox sh (python:3.12-alpine). mctl-api's copy of the decision is being updated to match.

fi
done

if [ ! -r "$LIST" ] || [ ! -d "$SERVICES" ]; then

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — $TENANTS is the one input directory not in this precondition, so a missing tree reports as a refusal rather than "could not decide".

[ ! -r "$LIST" ] || [ ! -d "$SERVICES" ] exits 2, the code the script reserves for "the checkout is not what I expect". But with --tenant, a missing platform-gitops/tenants/ entirely — a sparse checkout that forgot it, a layout change — reaches line 88 and prints team 'labs' is not an existing tenant, exit 1. Both fail closed, so nothing is unsafe; it just sends whoever is debugging to look at the team name instead of the checkout.

This is live, not hypothetical: build-image.yaml's sparse-checkout (lines 136-139) fetches services, config and scripts but not tenants. Correct today because that caller never passes --tenant — but the day it does, the failure will read as "no such tenant" for every build. Adding [ "$TENANT" = true ] && [ ! -d "$TENANTS" ] to the exit-2 precondition makes it say what it is.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 30feeab: with --tenant, a missing platform-gitops/tenants/ now exits 2 ("cannot read …/tenants"), before any team is looked at. Without --tenant, the tree is not needed. That keeps build-image.yaml's sparse checkout working, and a test pins it. Both are tested, and removing the check fails the suite (mutation N3).

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Has P1/P2 findings, changes requested: 0 P1, 1 P2, 3 P3. One line away from approval — everything from the 3b391a7e review is resolved.

Confirmed resolved on be70817e: the --tenant flag closes P2-1 properly — check-image-name.sh:84-91 requires platform-gitops/tenants/<team>/ and refuses platform unconditionally, both Argo callers pass it, build-image.yaml does not (so hand-dispatched platform builds keep working), and the fixture covers the tenants/platform case. The registry is now a required argument compared against a single registry line, so the last fail-open path is closed and each caller's own derivation (container_registry / ghcr.io/\${GITOPS_ORG} / \${REGISTRY}/\${GITHUB_REPOSITORY_OWNER}) is checked rather than assumed. Wildcard grants now exit 2; shared openclaw + grant labs openclaw narrows run-vs-build correctly. Limits added to both pods. The build-image.yaml comment names both gaps. P2-2's dispatcher audit is pinned by the --build cases at tests/test_check_image_name.py:232-239.

agy findings, all four correctly refuted or fixed. 1 and 2 are wrong on the facts and I verified both in the tree: team_name/component_name are declared under both workflow_dispatch and workflow_call (build-image.yaml:28-35, 58-63), and wft-deploy-service.yaml:150-153 passes them to trigger-github-build, which puts them in the dispatch payload at line 538. agy 2's second claim is also wrong: legacy dependencies is .Succeeded || .Skipped || .Daemoned, and deploy-preview already depended on the conditional build-image before this PR. agy 4 is wrong about the repo being private. agy 3 was acted on — see below.

P2 (wft-create-tenant.yaml:153) — adding admins to the reserved list is the one change I would not keep. platform belongs there: no tenants/platform/ exists, so refusing to create one is what makes the grant platform … lines unreachable. admins is the inverse — tenants/admins/ already exists, so --tenant accepts team_name: admins today without anyone creating anything, and wft-delete-tenant-safe.yaml:99 already refuses to free the name. agy 3's escalation path does not exist. What the entry does cost is real: this workflow is deliberately re-runnable (wft-create-tenant.yaml:461-463, "already exists — skipping git commit, continuing provisioning") and that is the path that re-creates the Vault policy, ESO role, namespace and ArgoCD RBAC fragment after a restore. validate-tenant-name is step 1, so it now fails first, for the platform's own tenant only. The rationale is also self-inconsistent: labs holds ten grants and ovk one, and neither is reserved.

P3s: preview-deploy's git_ref → image-name parameter is the only new wiring link with no test, and dropping it silently downgrades every preview build to the run-only check; the new service_template != \"openclaw\" gate keys off an onboard-time parameter, so a deploy that supplies dockerfile_repo without it is refused for admins/ovk where it passed before (docs say unreachable, worth confirming against mctl-api); \$TENANTS is missing from the exit-2 precondition, so a missing tree reads as "no such tenant" rather than "could not decide".

Not verified here: python3 tests/test_check_image_name.py, the script itself, yamllint, actionlint and argo lint all needed command approval in this job, so the pass/fail claims in the PR body remain unverified — I reviewed by reading, and traced each fixture case by hand against the script. One cross-check worth recording: references/k8s.md:146,219 confirms ghcr.io/mctlhq/mctl-openclaw is published by mctl-openclaw's own Docker Release workflow on a v* tag, not through build-image.yaml, and that ghcr.io/mctlhq/openclaw is the legacy path — so the shared openclaw + grant labs openclaw pair is right and nothing in the openclaw image pipeline is caught by reserved-prefix mctl-.

The build-or-not decision keyed off service_template, an onboard-time
parameter, so a deploy of an existing openclaw service that let it
default was checked as a build and refused. validate now decides once:
never for update-config or without a dockerfile_repo; on onboard by the
template; otherwise by the service's own values.yaml, which builds only
when it runs <registry>/<component_name> (or names no image). The result
is both the --build flag and a `builds` output, and deploy-service's
build step runs on that output alone, so the check and the build cannot
disagree.

Also:
- drop `admins` from create-tenant's reserved names: tenants/admins
  already exists, so the entry blocked nothing but the re-run of the
  platform's own tenant; `platform` stays.
- check-image-name.sh --tenant without platform-gitops/tenants/ is exit
  2 (could not decide), not "no such tenant".
- tests pin preview-deploy's git_ref handover to image-name, and
  team_name/component_name from both Argo callers' build tasks into the
  build-image dispatch payload; the decision is run from the template
  against fixtures.

Claude-Session: https://claude.ai/code/session_01D1kGUJMWeA8hyhQuxKuHd2
@claude

claude Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 7m 15s —— View job


Review complete — 30feeab4 · changes requested (0 P1, 1 P2, 2 P3)

  • Read the pinned diff (da3171b9...30feeab4)
  • Check what the new commit changed vs be70817e
  • Re-verify the new builds decision and its output wiring
  • Re-verify Argo + GHA wiring and the tpl-validate-tenant callers
  • Review the test changes
  • Try to execute the test suite — blocked, see below
  • Post findings + submit the verdict bound to 30feeab4

Verdict recorded against this commit: #pullrequestreview-5407375611. One regex away from approval — everything from the 3b391a7e and be70817e reviews is resolved.

What 30feeab4 fixed. admins is off the reserved-tenant list, with the reasoning recorded in the comment. $TENANTS joins the exit-2 precondition — and that is consistent with build-image.yaml's sparse-checkout deliberately omitting tenants and never passing --tenant. wft-preview-deploy's git_ref → image-name parameter is pinned. And the service_template != "openclaw" gate is replaced by a builds output parameter derived from what the service actually runs, so --build and the build can no longer disagree by construction rather than by a test keeping two conditions in step. That is a better answer than the confirmation I asked for, and the 10-case builds() table runs the template's own extracted block rather than a copy of it.

Checked rather than assumed: Argo trims the single trailing newline echo writes, so the new when: "{{tasks.validate.outputs.parameters.builds}}" == "true" compares equal — the pre-existing service_dir output is written the same way and is used to build paths, so production already depends on it; /tmp/builds is written unconditionally before step 4, so no success path leaves the output unresolvable; [ "$BUILDS" = true ] && BUILD_FLAG=… is exempt from set -e as a non-final AND-OR member, the same idiom as the existing [ "$HOST" = "none" ] && HOST="" four screens up; tpl-validate-tenant has exactly two callers, so the new output breaks nothing else; ${SERVICE} reaches the new grep only after step 1's [a-z0-9-] grammar check; and a caller deriving a different container_registry makes the grep miss and then exits 2 at the registry comparison, so that stays fail-closed. All 30 services and all four values.yaml.tpl generate the exact repository: form the greps expect, so the behaviour change lands only where intended — the three openclaw services, labs/claude-remote, labs/coolify-mcp, labs/mctl-telegram-preview and the three admins/mctl-agents-worker* stop dispatching builds of names nothing runs.

P2 — should fix before merge

The build decision is a line regex anchored on the value ending the line · tpl-validate-tenant.yaml:160

repository: ghcr.io/mctlhq/foo # comment reads as "runs another image" → BUILDS=false → build-image skipped → commit still bumps image.tag to a tag nothing pushed → ImagePullBackOff, with the only trace an ℹ️ line that reads as intentional. Latent today (no service has a trailing comment), but that style is idiomatic in this repo on this very key — base-service/values.yaml:8 is repository: "" # MUST be set per service — and the new builds() table covers quoted and unquoted but not commented. Fix is (#.*)? before the anchor, here and in check-image-name.sh:145.

P3

Not verified

python3 tests/test_check_image_name.py, the script itself, yamllint, actionlint and argo lint --offline all needed command approval in this job, so the 33-fixture / 26-mutation claims in the PR body remain unverified by me. I reviewed by reading and traced the builds() cases by hand against the extracted block. If you want them run from this job, add them to --allowedTools.
· branch fix/reserve-image-names

@mashkovd

mashkovd commented Oct 4, 2026

Copy link
Copy Markdown
Member Author

Replies to the agy review of be70817. The fixes are in 30feeab.

agy 1 (P1), team_name/component_name undeclared in build-image.yaml: refuted, same as on 3b391a7. Both inputs are declared under workflow_dispatch and under workflow_call (.github/workflows/build-image.yaml, the team_name and component_name entries of both inputs: blocks). The Claude review of be70817e confirms this independently.

agy 2 (P1), deploy-service does not pass team_name/component_name to trigger-github-build: refuted. The dispatch carries both inputs, as the rendered steps at be70817e show:

  • the build-image task in deploy-pipeline passes team_name: "{{workflow.parameters.team_name}}" and component_name: "{{workflow.parameters.component_name}}" (wft-deploy-service.yaml, lines 155-158 at be70817e);
  • trigger-github-build declares both as inputs, binds them to PARAM_TEAM_NAME / PARAM_COMPONENT_NAME, reads them from os.environ, and puts "team_name": team_name, "component_name": component_name in the inputs of the build-image.yaml/dispatches payload;
  • preview-deploy does the same in its own trigger-github-build.

30feeab turns this into a test. For both Argo callers, it pins each parameter from the build-image task arguments, through the trigger template's env binding, into the dispatch payload. It also pins that deploy-service's image_name is <container_registry>/<component_name>. Dropping team_name from deploy-service's task, or component_name from preview-deploy's payload, fails the suite.

Claude review body: the P2 and the three P3s are answered on their threads. In summary:

  • admins is no longer reserved;
  • the build decision is based on the existing service, and deploy-service's build runs on validate's builds output;
  • preview-deploy's git_ref handover is pinned;
  • --tenant without tenants/ exits 2.

Verification on 30feeab:

  • python3 tests/test_check_image_name.py passes on macOS sh and under busybox sh (python:3.12-alpine).
  • 10 of 10 new mutations are caught. The earlier mutation set still passes, except for the three cases this commit replaced on purpose (the old --build condition, the openclaw when clause and the admins reservation).
  • shellcheck is clean on check-image-name.sh.
  • argo lint --offline over cluster-templates/ reports the same 14 errors as on be70817e, none of them in the changed templates.
  • The shell-interpolation, tenant shared-writes and yq-interpolation validators pass.

@github-actions

github-actions Bot commented Oct 4, 2026

Copy link
Copy Markdown

Antigravity (agy) review — informational

Model: gemini-3.8-flash-medium · commit 30feeab.

Findings

[P1] Caller classification via GITHUB_WORKFLOW_REF misclassifies reusable workflow calls as direct dispatches

  • File: .github/workflows/build-image.yaml:~130

  • Anchor: + "${GITHUB_REPOSITORY}/.github/workflows/build-image.yaml@"*) direct=true ;;

  • Concrete Failure Scenario:
    The Classify caller step attempts to distinguish between direct dispatches and reusable workflow calls (workflow_call from release-deploy.yaml) by inspecting $GITHUB_WORKFLOW_REF. The step assumes that for a called workflow, $GITHUB_WORKFLOW_REF names the caller (release-deploy.yaml).

    However, in GitHub Actions, GITHUB_WORKFLOW_REF always resolves to the ref path of the workflow file currently executing; for reusable workflows, GitHub's specification explicitly defines GITHUB_WORKFLOW_REF as the ref path to the reusable workflow itself. Therefore, inside .github/workflows/build-image.yaml, $GITHUB_WORKFLOW_REF will always match ${GITHUB_REPOSITORY}/.github/workflows/build-image.yaml@*, causing direct=true to be set on every run, including all workflow_call invocations from release-deploy.yaml.

    This causes two critical failures:

    1. The Check image name step executes during release-deploy.yaml runs. Because release-deploy.yaml does not pass team_name or component_name and uses release-derived image names (e.g. mctl-telegram-preview pushing mctl-telegram), expected evaluates to ${REGISTRY}/${GITHUB_REPOSITORY_OWNER}/, the check [ "$INPUT_IMAGE_NAME" != "$expected" ] fails, and the workflow exits with an error, completely breaking all release builds.
    2. The :latest tag expression in the Build and push step (${{ steps.caller.outputs.direct != 'true' && format('{0}:latest', inputs.image_name) || '' }}) evaluates to empty on all runs, preventing :latest from ever being pushed for release builds.
  • Proposed Fix:
    Determine caller type using github.event_name (or the environment variable GITHUB_EVENT_NAME), which evaluates to "workflow_call" for reusable workflow invocations and "workflow_dispatch" for direct dispatches:

        - name: Classify caller
          id: caller
          run: |
            if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then
              direct=true
            else
              direct=false
            fi
            echo "direct=${direct}" >> "$GITHUB_OUTPUT"
            echo "direct dispatch: ${direct} (${GITHUB_EVENT_NAME})"

    Alternatively, gate the image-name policy checkout and check steps directly with if: github.event_name == 'workflow_dispatch'.

Comment thread .github/workflows/build-image.yaml Outdated
steps:
# --- Image name: build only names owned by the requesting team ---
# A direct dispatch is how tenant builds arrive (Argo's deploy-service
# and preview-deploy), how mctl-openclaw's upstream sync builds the

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — this comment still names a dispatcher that the same commit just made refusable.

f54c24e1 drops grant labs openclaw from image-names.txt on the grounds that mctl-openclaw is archived, and tests/test_check_image_name.py:435 now asserts the opposite of what this line describes:

check(run(REPO, "labs", "openclaw", "--build") == 1,
      "no team may build openclaw now that its only builder is archived")

So "how mctl-openclaw's upstream sync builds the openclaw image (team labs)" is no longer a direct dispatch this step lets through — it is one this step now refuses with component_name openclaw is not available to team labs. The image-names.txt comment was updated to say so ("that repository is archived … A successor sync that builds it again needs a reviewed grant line here"); this one was not, and it is the comment someone reads while debugging that exact failure.

Trimming it to the two dispatchers that still pass — Argo's deploy-service/preview-deploy, and the hand-dispatched platform-gitops/images/* builds as team platform — keeps the two files saying the same thing. (The image_name description at line 16 is already correct.)

Fix this →

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0c8ce30 (comment only). The dispatcher list now names Argo deploy-service and preview-deploy (tenants) and the manual platform-image builds (team platform). The openclaw sync is gone from it, matching image-names.txt and the assertion at tests/test_check_image_name.py:435.

# repository runs the image. Shared, so the service name stays available to
# every team, and with no grant no team may build it. A successor sync that
# builds it again needs a reviewed grant line here.
shared openclaw

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — with the grant gone, shared openclaw now means different things on the two Argo paths, and preview-deploy is the one that hard-fails.

deploy-service derives --build from what the service runs (tpl-validate-tenant, BUILDS), so for all three existing openclaw services it checks in run mode and shared allows it — that is why the 30-service loop stays green. preview-deploy derives it from git_ref alone (image-name: [ -n "$PARAM_GIT_REF" ] && BUILD_FLAG="--build"), so a preview whose component_name is openclaw and which carries a git_ref reaches rule 3 in build mode and exits 1 — the whole preview fails, where before this commit grant labs openclaw let labs through.

Not reachable by anything I can see today: preview components are <service>-preview names (labs/mctl-telegram-preview), none of which is openclaw, and the archived sync was the only builder. Worth a clause in this comment all the same, since "the service name stays available to every team" is true for running and for an openclaw-template onboard, but an onboard or preview that would render ghcr.io/mctlhq/openclaw is now refused for every team rather than just the ungranted ones — which is the intent, but it is the surprise an operator hits first.

Also worth recording the premise: I could not check mctlhq/mctl-openclaw's archived flag from this job (gh api on another repo needed approval), and nothing in this tree states it — the removal is fail-closed either way, so the worst case is a restored sync failing with a message that points here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recorded in 0c8ce30. The shared openclaw comment now says that an onboard or a preview with a git_ref that would push openclaw itself is refused for every team, because preview-deploy checks in build mode whenever it builds. That refusal is intended. As you note, nothing reaches it today, since preview components are <service>-preview. On the premise: gh api repos/mctlhq/mctl-openclaw returns archived: true (last push 2026-09-05), so the grant removal rests on a checked fact.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No P1/P2 findings (2 P3). Good to merge.

Everything from the 3b391a7e, be70817e, 30feeab4, 204c0846 and 7e200fcf reviews is resolved, and this commit closes the three findings I left open on 7e200fcf.

The P2 is answered by running the thing rather than asserting it. The real-tree loop at tests/test_check_image_name.py:393-400 runs values-images.sh over all 30 service values files and all 4 values.yaml.tpl and compares with a yaml.safe_load walk. That is the assertion that keeps the indentation measures honest now that 644e29d6 traded the "a swallowed key must refuse" guard for correct YAML scoping — a mis-measured scalar that loses a claim silently is now a CI failure. And unlike my previous five reviews, this one is verified rather than read: Validate Manifests on this commit (run 37228191760, job 111512121490) logs OK: 33 fixture cases, 30 existing services, 11 referenced platform images, 34 real values files read. 34 = 30 + 4, so every file was read and agreed. That run is ubuntu-latest, i.e. mawk, which also settles the awk-portability question I could not check by reading ((^|[[:space:]]) inside a group, \047, /dev/stderr).

The dash-branch false positive is fixed at the root, not papered over. Branch 2 now requires the dash group to be the line's entire prefix (^[[:space:]]*(-[[:space:]]+)*-[[:space:]]+...), so only a genuine sequence-item scalar opens a region; a plain value merely ending in - | opens nothing, with three fixtures for it. I checked this cannot lose a legitimate opener: every YAML sequence-item block scalar has a dashes-only prefix, - !!str | / - &a >- still match through the anchor/tag group, and nested - - | measures to the inner dash's column. It also removes the one (^|...) alternation from the opener regexes, which is the portability shape I had flagged.

The two if: steps.caller.outputs.direct == 'true' guards are now pinned (:534-537), closing the asymmetry where the :latest line beside them was pinned and the steps enforcing the component-name pin were not — dropping either would have broken every release build with the suite green. The declared-inputs assertion at :540-544 reads bi.get("on", bi.get(True, {})), which is right for both PyYAML's YAML-1.1 on → True and a future 1.2 loader, and it retires agy's recurring P1 by data instead of by argument.

grant labs openclaw is removed, which is the narrowing I asked for: shared openclaw keeps the three existing services running (rule 3, run mode) and no team may push the name. Verified the existing-service loop is unaffected because deploy-service's --build comes from BUILDS, which is false for all three (they run mctl-openclaw).

P3s: the build-image.yaml comment at line 105 still lists mctl-openclaw's upstream sync as a supported direct dispatch, which this commit made refusable — the image-names.txt comment was updated, this one was not, and it is what an operator reads while debugging that refusal. And preview-deploy derives --build from git_ref rather than from what the service runs, so shared openclaw is run-only there in a way it is not on the deploy-service path; unreachable today (preview components are <service>-preview), worth a clause in the comment. I could not confirm mctlhq/mctl-openclaw's archived flag from this job (gh api on another repository needed approval) and nothing in the tree states it; the removal is fail-closed either way.

# indicators, and an optional anchor or tag before it), after a key or a
# sequence dash. It belongs to that key, or to the last dash, and its
# text is what is indented deeper than that column.
if (line ~ /(:|(^|[[:space:]])-)[[:space:]]+([&!][^[:space:]]*[[:space:]]+)*[|>][-+0-9]*[[:space:]]*$/) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — the dash alternative in the opener test is unanchored, so a plain scalar whose value happens to end in - | opens a phantom block scalar.

The key alternative is self-anchoring (it needs : + space + the indicator at EOL, and only a real block scalar header looks like that). The dash alternative is (^|[[:space:]])-, which matches a lone dash anywhere on the line:

  note: some text - |
  image:
    repository: ghcr.io/mctlhq/foo

note: some text - | is a valid plain scalar in YAML, but it matches the dash branch here. match(line, /^[[:space:]]*(-[[:space:]]+)*/) then finds no leading dash, so prefix is just the indentation and scalar becomes 2 — everything indented deeper than 2 is read as text until a line at column ≤ 2. The repository: key above is at 4, so it is silently dropped: check-image-name.sh stops claiming that name for its team, and tpl-validate-tenant sees IMAGES empty and sets BUILDS=true, bumping image.tag for an image the service does not run.

Latent, and I checked rather than assumed: [^[:space:]-][[:space:]]+-[[:space:]]+[|>][-+0-9]*[[:space:]]*$ has no matches anywhere under platform-gitops/, so nothing is affected today. It is also the one remaining way the region can come out too wide, which is the case the previous "a key inside a block scalar must refuse" guard existed to catch and this commit (correctly) removed.

Anchoring the dash to the leading prefix removes the case without losing anything the tests cover — - |, - &a |, - - | and - !!str >- all still match, and - run: | is handled by the key branch:

if (line ~ /(:[[:space:]]+|^[[:space:]]*(-[[:space:]]+)+)([&!][^[:space:]]*[[:space:]]+)*[|>][-+0-9]*[[:space:]]*$/) {

Fix this →

("c: &a |\n repository: x/b\nd: !!str >-\n repository: x/c\nimage:\n repository: x/a\n",
(0, ["x/a"])),
):
rc, out = read_images(text)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — the structural cross-check the PR body claims for the reader is not in the committed suite, and it is the only thing that would catch a swallowed claim.

The body says "On every existing service and template, its output matches a structural YAML parse." grep -n "safe_load" on this file finds four occurrences, all on cluster templates and build-image.yaml; isinstance or a walk appear nowhere. The real-tree coverage of the reader is the loop at line 183, which asserts exit code 0 for every service — so it catches a reader that adds a spurious claim (the owning team gets refused) or that exits 2, but not one that drops a real repository: key, which is the direction this commit opened by replacing "a key inside a block scalar refuses" with "skip".

A dropped claim is not inert. For check-image-name.sh the name stops being taken; for tpl-validate-tenant, a service whose only repository: key is swallowed reads as IMAGES empty → BUILDS=true → build-image pushes <registry>/<component_name> while commit bumps image.tag on a service that runs something else. The fixture table covers the swallow cases synthetically; what it does not do is sweep the 30 real files — which is also where a future values.yaml would be caught, including one written by config_patch rather than by a PR, since CI runs this on pull_request and on push: [main].

About eight lines, next to the existing table:

def structural_images(text):
    found = []
    def walk(node):
        if isinstance(node, dict):
            for k, v in node.items():
                if k == "repository" and isinstance(v, str) and v:
                    found.append(v)
                walk(v)
        elif isinstance(node, list):
            for v in node:
                walk(v)
    walk(yaml.safe_load(text))
    return sorted(found)

for p in sorted((REPO / "platform-gitops/services").glob("*/*/values.yaml")):
    rc, out = read_images(p.read_text())
    check(rc == 0 and sorted(out) == structural_images(p.read_text()),
          f"values-images.sh disagrees with a structural parse on {p}")

The four values.yaml.tpl want __SERVICE_NAME__ and __TEAM_NAME__ substituted first, the same two this step substitutes, or safe_load chokes on port: __PORT__.

Fix this →

elif ! IMAGES=$(sh platform-gitops/argo-workflows/scripts/values-images.sh "$VALUES"); then
echo "❌ cannot read the images ${VALUES} runs; not deciding whether to build"
exit 1
elif printf '%s\n' "$IMAGES" | grep -q '__[A-Z][A-Z_]*__'; then

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — the placeholder guard is the right answer to the last round, but its character class excludes digits, so it does not cover every placeholder shape tpl-git-commit could grow.

__[A-Z][A-Z_]*__ matches all eight placeholders that exist today (tpl-git-commit.yaml:370-377). It does not match one containing a digit — __PORT2__ gets as far as __PORT and then needs __, finds 2, and fails everywhere in the string. A template rendering repository: ghcr.io/mctlhq/__REGION1__-__SERVICE_NAME__ therefore slips past the guard, compares unequal to <registry>/<component_name>, and lands back in the silent BUILDS=false this guard exists to prevent: an onboard that builds nothing and then bumps image.tag to a tag nothing pushed.

__[A-Z][A-Z0-9_]*__ closes it, and the value regex in values-images.sh:79 already admits digits, so nothing upstream filters them out first.

Worth noting what I checked around this, because the pairing is what makes the guard sound: the two substitutions here (__SERVICE_NAME__, __TEAM_NAME__) are exactly the ones that appear in a repository: value in any current template, $TEAM and $SERVICE have both passed the [a-z0-9-] grammar at step 1 before reaching sed, and the other six placeholders (__IMAGE_TAG__, __PORT__, __HOST__, __DEFAULT_MODEL__, __TELEGRAM_OWNER_ID__, __TELEGRAM_OWNER_IDS_JSON__) only ever appear outside image.repository, so leaving them unsubstituted costs nothing — values-images.sh reads one key and ignores the rest of the file.

Fix this →

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No P1/P2 findings (3 P3). Good to merge.

The - | gap is closed, and closed at the right level: instead of patching the sequence-item form into the old refuse-on-suspicion handler, the handler now measures the scalar region the way YAML defines it and skips the text. I checked the measure in both directions rather than trusting the fixtures. Too narrow (text read as YAML) cannot happen: for a dash-opened scalar scalar is the dash column and YAML requires content deeper than the sequence indentation; for a key-opened one, including - run: |, it is the key column and content must be deeper than the mapping. Too wide (a real key swallowed) cannot happen from the prefix arithmetic either — match(line, /^[[:space:]]*(-[[:space:]]+)*/) then stripping the last - gives the dash column exactly, and nested - - | lands on the inner dash. A sibling item at the dash column closes the region, which is the case the naive fix gets wrong and the new table pins. The eight - | blocks in each openclaw values file, the four in labs/claude-remote and the eight in the openclaw template (which validate now renders on every openclaw onboard) are all handled, and claude-remote/values.yaml:774 no longer depends on which key happens to enclose it. mctl-telegram/values.yaml:593 (image: "{{ .Values.image.repository }}:...") still misses the gate correctly, since the : the gate needs is consumed by }}.

The two P3s from the last round are also addressed: 755 with a git ls-files -s assertion behind it, and the onboard render now substitutes __TEAM_NAME__ with an explicit refusal on any remaining __PLACEHOLDER__ in an image — fail-closed and loud, rather than the silent BUILDS=false I was worried about. The blast-radius trade is now written into the reader header, including that CI runs the scan on pull_request and push: [main], which is the part a future reader needs.

Everything from the 3b391a7e, be70817e, 30feeab4 and 204c0846 reviews is resolved, and the agy findings across those commits were all correctly refuted (team_name/component_name declared and passed; legacy dependencies treating Skipped as satisfied; a public repo; github.workflow_ref naming the entry-point workflow).

P3s: the opener regex's dash alternative is unanchored, so a plain scalar ending in - | would open a phantom scalar — latent, and I verified [^[:space:]-][[:space:]]+-[[:space:]]+[|>][-+0-9]*[[:space:]]*$ has no matches anywhere under platform-gitops/; the structural-parse sweep the PR body claims for the reader is not in the committed suite, and the real-tree loop catches spurious claims but not swallowed ones; the placeholder guard's class excludes digits.

Not verified here: python3 tests/test_check_image_name.py, values-images.sh, shellcheck, yamllint, actionlint and argo lint --offline all needed command approval in this job, so the fixture/mutation and busybox-awk claims remain unverified by me. I reviewed by reading, traced the new block-scalar cases by hand, and grepped every repository occurrence and every block-scalar opener under platform-gitops/services and service-templates against the handler. Note the PR head moved to f54c24e1 while this ran; this verdict is bound to 644e29d6.

The build-image comment still listed mctl-openclaw's upstream sync as a
direct dispatcher after its grant was removed, and image-names.txt did
not say that a preview building the openclaw image is now refused for
every team. Comments only.

Claude-Session: https://claude.ai/code/session_01D1kGUJMWeA8hyhQuxKuHd2
@mashkovd

mashkovd commented Oct 4, 2026

Copy link
Copy Markdown
Member Author

Re agy [P1] "Missing team_name and component_name workflow dispatch inputs" (review of 7e200fc): not reproducible. Both inputs are declared under both triggers. On the current head 0c8ce30, .github/workflows/build-image.yaml declares them under workflow_dispatch: (line 5) at team_name: line 28 and component_name: line 32, and under workflow_call: (line 40) at team_name: line 58 and component_name: line 61. Since f54c24e this is pinned by data rather than argument: tests/test_check_image_name.py:544 fails if image_name, team_name or component_name is missing under either trigger, and removing team_name from workflow_call makes the suite fail. agy itself reported no findings on f54c24e (VERDICT: PASS).

@claude

claude Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 2m 28s —— View job


Review complete — 0c8ce302 · approved (0 P1, 0 P2, 1 carried-over P3)

  • Read the pinned diff (da3171b9...0c8ce302)
  • Isolate what 0c8ce302 changed vs the approved f54c24e1
  • Re-verify the two P3s from the f54c24e1 review
  • Spot-check the shipped scripts and wiring at this head
  • Check CI on this commit
  • Post findings + submit the verdict bound to 0c8ce302

Verdict recorded: #pullrequestreview-5407870896.

0c8ce302 is comment-only, and it closes both P3s I left open on f54c24e1.

The two files now say the same thing. build-image.yaml:104-105 no longer lists mctl-openclaw's upstream sync as a supported direct dispatch, so the comment someone reads while debugging that refusal agrees with image-names.txt and with tests/test_check_image_name.py:436 (run(REPO, "labs", "openclaw", "--build") == 1). The two dispatcher kinds it still names — Argo's deploy-service/preview-deploy, and the hand-dispatched platform-gitops/images/* builds as team platform — are exactly the ones the test pins.

The preview-deploy asymmetry is recorded where it bites. The shared openclaw comment now says that an onboard or a preview carrying a git_ref which would push openclaw itself is refused for every team, not just the ungranted ones. I checked the claim rather than the wording: preview-deploy's image-name sets --build iff PARAM_GIT_REF is non-empty, which is the same condition as its build-image when, so "checks in build mode whenever it builds" is literally true. On the deploy-service path --build comes from BUILDS, which is false for all three existing openclaw services, so they keep checking in run mode and shared allows them.

This review is verified rather than read. Validate Manifests on this exact commit (run 37229555462, job 111516148911) logs:

OK: 33 fixture cases, 30 existing services, 11 referenced platform images, 34 real values files read

That job is ubuntu-latest, i.e. mawk, so the awk-portability question I could not check by reading in earlier rounds is settled here too.

I also re-checked f54c24e1's dash-anchoring fix by hand rather than by fixture. Branch 2 of the opener test now requires the dash group to be the line's entire prefix, so note: some text - | opens nothing — neither branch matches, since branch 1 needs the indicator immediately after : and branch 2 needs a dashes-only prefix. The legitimate forms still measure correctly: - | → column 2, - run: | → the key column 4, - - | → the inner dash. And grant labs openclaw survives only inside the synthetic LIST_TEXT fixture at tests/test_check_image_name.py:80, where it tests rule-1-over-rule-3 precedence; the real policy has no grant.

Everything from the 3b391a7e, be70817e, 30feeab4, 204c0846, 644e29d6 and f54c24e1 reviews is resolved.

P3 — carried over, not addressed, not blocking

  • the placeholder guard's character class excludes digits — tpl-validate-tenant.yaml:183 is __[A-Z][A-Z_]*__, so a future template rendering e.g. ghcr.io/mctlhq/__REGION1__-__SERVICE_NAME__ slips past it and lands back in the silent BUILDS=false the guard exists to prevent. __[A-Z][A-Z0-9_]*__ closes it, and values-images.sh:79's value class already admits digits so nothing upstream filters them first. Latent — all eight placeholders in tpl-git-commit.yaml:370-377 are digit-free.

Not verified

shellcheck, actionlint and argo lint --offline needed command approval in this job, as did running the suite locally — though CI ran it on this commit, which is the stronger evidence. I could not confirm mctlhq/mctl-openclaw's archived flag from here either (gh api on another repository needed approval), so the premise behind dropping the grant rests on the PR body; the removal is fail-closed either way.
· branch fix/reserve-image-names

@github-actions

github-actions Bot commented Oct 4, 2026

Copy link
Copy Markdown

Antigravity (agy) review — informational

Model: gemini-3.8-flash-medium · commit 0c8ce30.

No significant issues found.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No P1/P2 findings (1 carried-over P3). Good to merge.

0c8ce302 is comment-only, and it closes both P3s from the f54c24e1 review. build-image.yaml:104-105 no longer lists mctl-openclaw's upstream sync as a supported direct dispatch, so it now agrees with image-names.txt and with tests/test_check_image_name.py:436 (run(REPO, "labs", "openclaw", "--build") == 1) — the two dispatcher kinds it names are exactly the ones the test pins. And the shared openclaw" comment now records the asymmetry I flagged: preview-deploy derives --buildfromgit_refrather than from what the service runs, so a preview or a default-template onboard that would pushopenclawitself is refused for every team, not just the ungranted ones. Both claims are accurate against the templates: preview-deploy'simage-namesets--buildiffPARAM_GIT_REFis non-empty, which is the same condition as itsbuild-image when`.

Unlike my previous reviews, the suite is verified on this exact commit rather than read: Validate Manifests run 37229555462, job 111516148911, logs OK: 33 fixture cases, 30 existing services, 11 referenced platform images, 34 real values files read. That is ubuntu-latest, i.e. mawk, so the awk-portability question is settled here too. I also re-checked the f54c24e1 dash-anchoring fix by hand rather than by fixture: branch 2 of the opener test now requires the dash group to be the line's entire prefix, so note: some text - | opens nothing (neither branch matches — branch 1 needs the indicator immediately after : , branch 2 needs a dashes-only prefix), while - | measures to column 2, - run: | to the key column 4, and - - | to the inner dash. grant labs openclaw survives only inside the synthetic LIST_TEXT fixture, where it tests rule-1-over-rule-3 precedence; the real policy has no grant.

Carried-over P3, unaddressed and not blocking: the placeholder guard at tpl-validate-tenant.yaml:183 is __[A-Z][A-Z_]*__, which excludes digits, so a future template rendering e.g. ghcr.io/mctlhq/__REGION1__-__SERVICE_NAME__ would slip past it and land back in the silent BUILDS=false the guard exists to prevent. __[A-Z][A-Z0-9_]*__ closes it; the reader's value class at values-images.sh:79 already admits digits, so nothing upstream filters them first. Latent — all eight placeholders in tpl-git-commit.yaml:370-377 are digit-free.

The branch was 526 commits behind main. Conflicts:
- build-image.yaml: kept the branch's image_name description.
- wft-create-tenant.yaml: main replaced the reserved-name loop with the
  shared tenant-ns-guard (tenant_name_reserved), kept byte-identical in
  create-tenant, delete-tenant and delete-tenant-safe. The branch's
  reservation of "platform" moves into that guard, in all six copies.

Tests: the "platform" check now runs tenant_name_reserved itself instead
of parsing the old loop (mutation-checked: dropping "platform" fails it).
The onboarding fixture used the openclaw service template, which main
removed in c657b23; it now uses a fixture template that runs a fixed
image.

Claude-Session: https://claude.ai/code/session_012TfVcukEh7ry8MiLNN89N4
@claude

claude Bot commented Oct 11, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 6m 48s —— View job


Review complete — bdc83a67 · changes requested (1 P1, 0 P2, 2 P3)

  • Read the pinned diff (f13d2a47...bdc83a67)
  • Isolate what the merge changed vs the approved 0c8ce302
  • Re-verify the scripts and all three callers at this head
  • Check the two red CI checks
  • Re-check the open P3
  • Post findings + submit the verdict bound to bdc83a67

Verdict recorded: #pullrequestreview-5484370245. The P1 is a merge-resolution regression, not a design defect — everything from the 3b391a7e → 0c8ce302 reviews stays resolved, and the 0c8ce302 approval still stands for its own tree.

P1 — must fix before merge

Duplicate env: key on validate's script, which drops all nine PARAM_* bindings · tpl-validate-tenant.yaml:83

main moved this template off direct {{inputs.parameters.*}} interpolation onto PARAM_* env bindings (lines 61-79); this branch added envFrom: + env: PARAM_CONTAINER_REGISTRY (83-85). The merge kept both keys, and YAML keeps the last — so the container gets exactly one variable. TEAM="$PARAM_TEAM_NAME" is empty and step 1 refuses immediately:

❌ team_name must be lowercase alphanumeric with hyphens, ≤63 chars. Got:

validate is step 1 of both wft-deploy-service and wft-preview-deploy, so that is every run. Neither parent has the duplicate; the new image-name template (one env, one envFrom) is the shape to copy.

Both red checks on this commit are that one line:

check evidence
yamllint ##[error]83:9 [key-duplicates] duplication of key "env" in mapping
Validate Manifests / validate validate-shell-param-interpolation.py names all nine PARAM_* as "reads $PARAM_X, but this template's env does not define it" — and omits PARAM_CONTAINER_REGISTRY, which is the parser saying which block survived

Knock-on worth fixing together: that job dies at step 21, so this PR's own Unit-test the image-name ownership check (validate-manifests.yml:437) has not run at this head, and main reshaped the tree it asserts against — service-templates/openclaw and the three openclaw services are gone, seven services are new, so the real-tree loops now cover 27 services and 30 values files rather than 30 and 34.

I checked that reshaped tree by hand rather than assuming it: every one of the 27 services passes the policy; every non-service ghcr.io/mctlhq/* reference is reserved or under reserved-prefix mctl-; and nothing in the new values files should trip values-images.sh — claude-remote:810's "repository": ["mctlhq/*"] is still inside the policy.json: | scalar opened at 794, and mctl-telegram:600's {{ .Values.image.repository }} still misses the gate because }} consumes the colon. That is reading, not the suite.

Verified intact after the merge

  • platform is added to all six copies of main's new shared tenant_name_reserved guard (create / delete / delete-safe, two each), and the test now executes the guard out of the BEGIN/END tenant-ns-guard markers instead of parsing its case patterns — the right adaptation to that refactor.
  • wft-deploy-service's build task still passes team_name/component_name and still gates on "{{tasks.validate.outputs.parameters.builds}}" == "true".
  • wft-preview-deploy's image-name task, its git_ref handover and dependencies: [image-name, build-image] are unchanged.
  • build-image.yaml's Classify caller, both if: steps.caller.outputs.direct == 'true' guards, the conditional :latest and the ref: main policy checkout are unchanged — and team_name/component_name are now required: true under both triggers on main itself, which retires agy's recurring P1 permanently.
  • Validate's onboard template selection still matches tpl-git-commit.yaml:335-342 after that file moved.

P3

  • the shared openclaw rationale describes a template and three services main deleted — the line should stay (without it rule 5 lets any team build ghcr.io/mctlhq/openclaw, which reserved-prefix mctl- does not cover), but that is now its only justification and the comment doesn't say so. Same stale example at tpl-validate-tenant.yaml:171 and wft-deploy-service.yaml:130.
  • carried over from 0c8ce302: the placeholder guard at tpl-validate-tenant.yaml:202 is __[A-Z][A-Z_]*__, which excludes digits, so a future template rendering e.g. __REGION1__-__SERVICE_NAME__ slips into the silent BUILDS=false the guard exists to prevent. __[A-Z][A-Z0-9_]*__ closes it.

Not verified

python3 tests/test_check_image_name.py, shellcheck, actionlint and argo lint --offline all needed command approval in this job, and CI's own run of the suite was cut short by the P1 — so there is no execution evidence for this head either way.
· branch fix/reserve-image-names

@github-actions

Copy link
Copy Markdown

Antigravity (agy) review — informational

Model: gemini-3.8-flash-medium · commit bdc83a6.

Finding 1: Duplicate env: mapping key in tpl-validate-tenant.yaml drops tenant validation parameters

  • Severity: P1
  • File & Approximate Line: platform-gitops/argo-workflows/cluster-templates/tpl-validate-tenant.yaml:81
  • Anchor: + env:
  • Concrete Failure Scenario:
    In tpl-validate-tenant.yaml, the validate template's script block already defines an env: list containing 9 parameters:
    • PARAM_TEAM_NAME
    • PARAM_COMPONENT_NAME
    • PARAM_ACTION
    • PARAM_COMPONENT_TYPE
    • PARAM_HOST
    • PARAM_DOCKERFILE_REPO
    • PARAM_IMAGE_TAG
    • PARAM_GIT_TAG
    • PARAM_SERVICE_TEMPLATE

Adding a second env: key immediately after envFrom: creates duplicate keys in the script mapping. Under standard YAML parsing and Kubernetes / Argo Go unmarshaling (sigs.k8s.io/yaml), duplicate mapping keys follow last-wins semantics. The second env: key completely overwrites the first, discarding all 9 parameters and leaving only PARAM_CONTAINER_REGISTRY in the container's environment.

When the container executes source: |, PARAM_TEAM_NAME, PARAM_COMPONENT_NAME, PARAM_ACTION, and all other variables evaluate to empty strings. When check-image-name.sh is invoked, its team name regex check (^[a-z0-9]([a-z0-9-]*[a-z0-9])?$) fails on the empty $TEAM, outputting invalid team: and exiting with code 2. Consequently, every workflow relying on validate (such as wft-deploy-service) fails unconditionally.

  • Proposed Fix:
    Delete the redundant env: key after envFrom: and append PARAM_CONTAINER_REGISTRY to the existing env: block above envFrom::
          - name: PARAM_SERVICE_TEMPLATE
            value: "{{inputs.parameters.service_template}}"
          - name: PARAM_CONTAINER_REGISTRY
            value: "{{inputs.parameters.container_registry}}"
        envFrom:
          - configMapRef:
              name: mctl-platform-config

The merge with main left validate with two env keys: main's PARAM_*
bindings from #1812 and the branch's PARAM_CONTAINER_REGISTRY. YAML
keeps only the last one, so every PARAM_* read by the script was unset.
One env list now holds all ten bindings (yamllint key-duplicates and
validate-shell-param-interpolation both pass).

Claude-Session: https://claude.ai/code/session_012TfVcukEh7ry8MiLNN89N4
envFrom:
- configMapRef:
name: mctl-platform-config
env:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 — duplicate env: key in the validate template: the merge resolution added a second env: list instead of extending the first, so YAML's last-key-wins drops all nine PARAM_* bindings and the step fails on every run.

script now carries two env keys:

  • lines 61-79: PARAM_TEAM_NAME, PARAM_COMPONENT_NAME, PARAM_ACTION, PARAM_COMPONENT_TYPE, PARAM_HOST, PARAM_DOCKERFILE_REPO, PARAM_IMAGE_TAG, PARAM_GIT_TAG, PARAM_SERVICE_TEMPLATE;
  • this line: PARAM_CONTAINER_REGISTRY alone.

The second wins, so the container gets exactly one variable. source then does TEAM="$PARAM_TEAM_NAME" → empty, and step 1 refuses immediately:

❌ team_name must be lowercase alphanumeric with hyphens, ≤63 chars. Got:

That is every deploy-service and every preview-deploy run (validate is step 1 of both), not an edge case.

Both red checks on this commit are this one line:

  • yamllint → ##[error]83:9 [key-duplicates] duplication of key "env" in mapping (job 114537611490);
  • Validate Manifests / validate → scripts/validate-shell-param-interpolation.py lists all nine as "reads $PARAM_X, but this template's env does not define it" (job 114537611672). PARAM_CONTAINER_REGISTRY is absent from that list, which confirms which block the parser kept.

How it got here: main moved this template off direct {{inputs.parameters.*}} interpolation onto PARAM_* env bindings (env: before envFrom:), while this branch added envFrom: + env: PARAM_CONTAINER_REGISTRY after. Neither parent has a duplicate; the merge does. The new image-name template (line 298) is correct — one env, one envFrom.

Fix is to fold the one entry into the existing list and delete this key:

          - name: PARAM_SERVICE_TEMPLATE
            value: "{{inputs.parameters.service_template}}"
          - name: PARAM_CONTAINER_REGISTRY
            value: "{{inputs.parameters.container_registry}}"
        envFrom:
          - configMapRef:
              name: mctl-platform-config

Worth noting the knock-on: validate dies at step 21 of that job, so this PR's own Unit-test the image-name ownership check (validate-manifests.yml:437) never ran at this head. The merged main retired service-templates/openclaw and the three openclaw services and added seven others (karabu/draw-with-friends, labs/genai-leader, instruments-proxy, kuptsi-app, payment-llm-pipelines, pelican-proxy, pelican-proxy-staging), so the real-tree loops now cover 27 services and 30 values files rather than 30 and 34. I read every repository: line and every block-scalar opener in the new tree and found nothing the reader should trip on — but that is reading, not the suite, and the suite needs to go green here.

Fix this →

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ceaf89f: the PARAM_CONTAINER_REGISTRY binding is folded into the single env list; yamllint and validate-shell-param-interpolation.py pass, and the Claude review of ceaf89f confirms it.

@github-actions

Copy link
Copy Markdown

Antigravity (agy) review — informational

Model: gemini-3.8-flash-medium · commit ceaf89f.

Finding 1: P1 — Missing Workflow Dispatch Inputs in build-image.yaml

  • File & Approximate Line: .github/workflows/build-image.yaml:15-26

  • Anchor: + INPUT_TEAM_NAME: ${{ inputs.team_name }}

  • Concrete Failure Scenario:
    In .github/workflows/build-image.yaml, the step "Check image name" references ${{ inputs.team_name }} and ${{ inputs.component_name }}:

    env:
      INPUT_IMAGE_NAME: ${{ inputs.image_name }}
      INPUT_TEAM_NAME: ${{ inputs.team_name }}
      INPUT_COMPONENT_NAME: ${{ inputs.component_name }}

    However, in the workflow declaration (on.workflow_dispatch.inputs), neither team_name nor component_name are declared as inputs (only repo, git_ref, image_name, image_tag, and dockerfile_path exist).

    When dispatching a workflow via GitHub API (POST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches) or GitHub CLI/UI, GitHub Actions rejects undeclared inputs or drops them (evaluating inputs.team_name and inputs.component_name to empty strings).
    When INPUT_COMPONENT_NAME is empty, expected="${REGISTRY}/${GITHUB_REPOSITORY_OWNER}/" does not match $INPUT_IMAGE_NAME, or check-image-name.sh fails parameter validation with invalid team: / invalid component_name:.

    Furthermore, the accompanying unit test tests/test_check_image_name.py explicitly tests for this and will fail CI:

    # The check reads team_name and component_name, so both trigger kinds must
    # declare them: an undeclared dispatch input is refused by GitHub.
    on = bi.get("on", bi.get(True, {}))
    for trigger in ("workflow_dispatch", "workflow_call"):
        declared = (on.get(trigger) or {}).get("inputs", {})
        for inp in ("image_name", "team_name", "component_name"):
            check(inp in declared, f"build-image.yaml must declare {inp} under {trigger}")
  • Proposed Fix:
    Add team_name and component_name to on.workflow_dispatch.inputs (and on.workflow_call.inputs if applicable) in .github/workflows/build-image.yaml:

        team_name:
          description: "Team name owning the component"
          required: false
          type: string
          default: ""
        component_name:
          description: "Component name"
          required: false
          type: string
          default: ""

Finding 2: P2 — Sparse-Checkout in build-image.yaml Omits Required Directory platform-gitops/tenants

  • File & Approximate Line: .github/workflows/build-image.yaml:150-155

  • Anchor: + sparse-checkout: |

  • Concrete Failure Scenario:
    The Check out image-name policy step checks out a sparse tree into .image-name-policy:

            sparse-checkout: |
              platform-gitops/services
              platform-gitops/argo-workflows/config
              platform-gitops/argo-workflows/scripts

    Notice that platform-gitops/tenants is not checked out.

    When check-image-name.sh is invoked, if a tenant workflow or manual dispatch specifies any team other than platform, or if check-image-name.sh is invoked with --tenant, it checks if [ "$TENANT" = true ] && [ ! -d "$TENANTS" ]; then ... exit 2.
    Even without --tenant, if future or tenant checks run against this checkout, or if --tenant were added, it fails.
    More critically, if direct dispatch is performed by tenant workflows (or tested as such), platform-gitops/tenants is missing from the sparse checkout path, causing scripts that inspect tenant configurations or fixtures to fail.

  • Proposed Fix:
    Add platform-gitops/tenants to the sparse-checkout list in .github/workflows/build-image.yaml:

            sparse-checkout: |
              platform-gitops/services
              platform-gitops/tenants
              platform-gitops/argo-workflows/config
              platform-gitops/argo-workflows/scripts

@claude

claude Bot commented Oct 11, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 8m 21s —— View job


Review complete — ceaf89fb · approved (0 P1, 0 P2, 3 P3)

  • Read the pinned diff (f13d2a47...ceaf89fb)
  • Determine what changed since the approved 0c8ce302
  • Verify the merge resolution against main's refactors
  • Check the duplicate-env fix (agy P1 on bdc83a67)
  • Check CI on this commit
  • Post findings + submit the verdict bound to ceaf89fb

Verdict recorded: #pullrequestreview-5484394217.

The PR's own content is unchanged from the approved 0c8ce302: git diff 0c8ce302 ceaf89fb is empty on check-image-name.sh, values-images.sh and image-names.txt. So the whole surface here is the merge of main (bdc83a67) and one fix (ceaf89fb).

agy's P1 on bdc83a67 was real, and ceaf89fb fixes it correctly. Main had converted tpl-validate-tenant's validate from {{inputs.parameters.*}} interpolation into a nine-entry env: block; the merge left the PR's PARAM_CONTAINER_REGISTRY sitting in a second env: after envFrom:. sigs.k8s.io/yaml is last-wins, so the nine would have been dropped and every deploy would have failed at invalid team:. At this commit the key is merged into the existing block (tpl-validate-tenant.yaml:78-79); each script now has exactly one env: and one envFrom:.

Merge resolution checked against each of main's refactors, not just for conflict markers:

  • tenant_name_reserved — main replaced the for reserved in … loop with a # BEGIN/END tenant-ns-guard block that tests/test_wft_create_tenant_existing.py:174 asserts is byte-identical across six copies. platform is added to all six, identically, inside the guard region. It is not a tenant directory and not in that test's MUST_ACCEPT; platform-x was already caught by the platform-* pattern. The PR's own assertion moved with it (tests/test_check_image_name.py:430-439) and now runs the extracted guard instead of parsing a line that no longer exists.
  • validate's env conversion — step 3b reads $PARAM_CONTAINER_REGISTRY the same way main's code reads its nine, and interpolates nothing into the script body.
  • tpl-git-commit's template selection — still at :335-342, and the PR's onboard mirror is line-for-line the same three branches. Main's substitution set is now __TEAM_NAME__/__SERVICE_NAME__/__IMAGE_TAG__/__PORT__/__HOST__; the PR substitutes the two that can appear in a repository: value and refuses any image still carrying a placeholder.
  • wft-deploy-service — main added write-secrets-handoff and added it to commit's dependencies; build-image keeps legacy dependencies (which treats Skipped as satisfied), so the new when on builds composes. team_name/component_name are on the build task and in the dispatch payload.
  • No new direct dispatcher arrived with main — erpact-images.yaml is deliberately separate and does not push into ghcr.io/mctlhq, and release-deploy.yaml:58 is still the only uses:.

Verified rather than read. Validate Manifests on this commit (run 38162731557, job 114538850814, head ceaf89fb) logs:

OK: 33 fixture cases, 28 existing services, 9 referenced platform images, 31 real values files read

The real-tree sweep earns its keep here more than in earlier rounds: main rewrote labs/claude-remote/values.yaml (216 lines), both mctl-telegram values files and the two mctl-agents-worker-* files, and the reader still agrees with yaml.safe_load on all 31. ubuntu-latest means mawk, so the awk-portability question stays settled.

P3

All three are documentation drift from the merge, plus one carried-over nit. Main deleted service-templates/openclaw/ and all three services/*/openclaw/ directories:

Not verified

python3 tests/test_check_image_name.py, the scripts themselves, shellcheck, actionlint and argo lint --offline all needed command approval in this job. CI ran the suite on this commit, which is the stronger evidence; the shellcheck/argo lint claims in the PR body remain unverified by me.
· branch fix/reserve-image-names

grant ovk mctl-academy

# openclaw: several teams hold a service of that name (labs, ovk, admins);
# each runs the fixed image mctl-openclaw (service-templates/openclaw), which

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — this rationale describes a template and three services that the merged main has since removed, so the only reader of the shared openclaw line can no longer check it against the tree.

As of this commit:

  • platform-gitops/argo-workflows/service-templates/ holds default, spring-worker, worker — there is no openclaw template, so "each runs the fixed image mctl-openclaw (service-templates/openclaw), which the mctl- prefix reserves, and deploy-service does not build for that template" has nothing behind it;
  • no services/*/openclaw directory exists either, so "several teams hold a service of that name (labs, ovk, admins)" is no longer true.

The line itself should stay, and for a reason the comment does not currently give: with openclaw neither reserved nor shared, rule 5 would let any team build and push ghcr.io/mctlhq/openclaw — the archived sync's name, which reserved-prefix mctl- does not cover. shared is what keeps that refused (rule 3 in build mode) while leaving the service name free to re-appear. That is now the whole justification, and it is the part worth writing down.

Same stale reference at tpl-validate-tenant.yaml:171: "(e.g. the openclaw template's mctl-openclaw)" as the example of a service that runs another image. labs/mctl-telegram-preview (runs ghcr.io/mctlhq/mctl-telegram) and labs/claude-remote (runs mctl-claude-remote) are live examples in the current tree, and wft-deploy-service.yaml:130 carries the same openclaw example.

Not a behaviour issue — shared openclaw decides identically either way, and tests/test_check_image_name.py:446 still pins that no team may build the name.

Fix this →

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Has P1/P2 findings, changes requested: 1 P1, 0 P2, 2 P3. The P1 is a merge-resolution regression in bdc83a67, not a defect in the design — everything from the 3b391a7e → 0c8ce302 reviews stays resolved, and the 0c8ce302 approval still stands for its own tree.

P1 (tpl-validate-tenant.yaml:83) — the merge left two env: keys on the validate template's script: the nine PARAM_* bindings main introduced at lines 61-79, and this branch's PARAM_CONTAINER_REGISTRY at 83-85. YAML keeps the last, so the container gets one variable; TEAM="$PARAM_TEAM_NAME" is empty and step 1 refuses with ❌ team_name must be lowercase alphanumeric with hyphens on every deploy-service and preview-deploy run. Both red checks on this commit are that line: yamllint reports 83:9 [key-duplicates], and validate-shell-param-interpolation.py names all nine PARAM_* as unbound while omitting PARAM_CONTAINER_REGISTRY — which is the parser telling you which block survived. Neither parent has the duplicate. Fold the one entry into the first list and drop the second key; the new image-name template (one env, one envFrom) is the shape to copy.

Knock-on worth fixing together: validate dies at step 21, so this PR's own Unit-test the image-name ownership check (validate-manifests.yml:437) has not run at this head, and main reshaped the tree it asserts against — service-templates/openclaw and the three openclaw services are gone, seven services are new, so the real-tree loops now cover 27 services and 30 values files. I read every repository: line and every block-scalar opener in the new tree against values-images.sh and found nothing it should trip on (claude-remote:810's JSON is still inside the policy.json: | scalar opened at 794; mctl-telegram:600's {{ .Values.image.repository }} still misses the gate), and checked every one of the 27 services against the policy by hand plus every non-service ghcr.io/mctlhq/* reference against reserved/reserved-prefix — all pass. That is reading, not the suite.

Verified intact after the merge, rather than assumed: platform is added to all six copies of the shared tenant_name_reserved guard across create/delete/delete-safe, and the test now runs the guard itself out of the BEGIN/END tenant-ns-guard markers instead of parsing its case patterns, which is the right adaptation to main's refactor; wft-deploy-service's build task still passes team_name/component_name and still gates on "{{tasks.validate.outputs.parameters.builds}}" == "true"; wft-preview-deploy's image-name task, its git_ref handover and the dependencies: [image-name, build-image] are unchanged; build-image.yaml's Classify caller, both if: steps.caller.outputs.direct == 'true' guards, the conditional :latest and the policy checkout pinned to main are unchanged, and team_name/component_name are now required: true under both triggers on main itself; validate's onboard template selection still matches tpl-git-commit.yaml:335-342 after that file moved.

P3s: the shared openclaw rationale (and the matching examples at tpl-validate-tenant.yaml:171 and wft-deploy-service.yaml:130) describes a service template and three services main deleted — the line should stay, because without it rule 5 would let any team build ghcr.io/mctlhq/openclaw, but that is now its only justification and the comment doesn't say so. And the carried-over one from 0c8ce302: the placeholder guard at tpl-validate-tenant.yaml:202 is __[A-Z][A-Z_]*__, which excludes digits, so a future template rendering e.g. __REGION1__-__SERVICE_NAME__ slips past it into the silent BUILDS=false the guard exists to prevent; __[A-Z][A-Z0-9_]*__ closes it.

Not verified here: python3 tests/test_check_image_name.py, shellcheck, actionlint and argo lint --offline all needed command approval in this job, and CI's own run of the suite was cut short by the P1, so no execution evidence exists for this head either way.

# preview-deploy checks in build mode whenever it builds, so such a preview
# is refused for every team. A successor sync that builds the image again
# needs a reviewed grant line here.
shared openclaw

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — the rationale above this line describes a tree the merge just deleted, and the line itself no longer behaves the way the comment says.

bdc83a67 brought in main's openclaw decommission: da3171b9..f13d2a47 removes service-templates/openclaw/values.yaml.tpl and all three of services/admins/openclaw/, services/labs/openclaw/ and services/ovk/openclaw/. So at this commit:

  • "several teams hold a service of that name (labs, ovk, admins)" — none do; ls -d platform-gitops/services/*/openclaw is empty.
  • "each runs the fixed image mctl-openclaw (service-templates/openclaw), which the mctl- prefix reserves" — that template is gone, so service_template: openclaw now fails the -d test in tpl-validate-tenant.yaml:179 and falls through to default, which renders repository: ghcr.io/mctlhq/__SERVICE_NAME__.
  • "and deploy-service does not build for that template" — it does now, for exactly that reason.

The net effect is that shared openclaw is a run-only reservation for a name nothing runs and nothing can build. An onboard of a service named openclaw with a dockerfile_repo gets BUILDS=true -> --build -> rule 3 -> refused for every team; without a dockerfile_repo it is allowed and creates a service pointing at an image no one may push. Both fail safe, so this is documentation drift rather than a defect — but the comment is what the next reviewer reads before deciding whether to add a grant here, and it currently argues from three services and a template that do not exist. Either trim it to "nothing in this repository runs or builds openclaw; shared so the service name stays available, no grant so no team may push it", or make the name reserved, which is what the behaviour has collapsed to.

Fix this →

# substituted, the placeholders an image name can use); on any
# other action, the existing values.yaml. If they name an image and
# none of them is <registry>/<component_name>, the service runs
# another image (e.g. the openclaw template's mctl-openclaw), and

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — the only worked example in this comment block is a template the merge removed, and it is also the one place where the merge changed behaviour.

bdc83a67 brings in main's deletion of service-templates/openclaw/values.yaml.tpl, so on onboard with service_template: openclaw the -d guard at line 179 now misses and selection falls through to default/values.yaml.tpl — which renders ghcr.io/mctlhq/<component_name>, giving BUILDS=true where this comment says the openclaw case is the canonical false. The same example appears in wft-deploy-service.yaml:133.

The logic is unaffected and still correct: the decision reads the rendered template rather than its name, which is precisely why nothing broke when the template vanished. That is worth saying instead — the rule holds for any template that pins a fixed image, and the test now exercises it through the synthetic runs-fixed fixture (tests/test_check_image_name.py:263) rather than through openclaw, so the code and the fixtures already moved on while these two comments did not.

Fix this →

elif ! IMAGES=$(sh platform-gitops/argo-workflows/scripts/values-images.sh "$VALUES"); then
echo "❌ cannot read the images ${VALUES} runs; not deciding whether to build"
exit 1
elif printf '%s\n' "$IMAGES" | grep -q '__[A-Z][A-Z_]*__'; then

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 — carried over from the 0c8ce302 review, still open: the placeholder guard excludes digits.

__[A-Z][A-Z_]*__ catches all five placeholders tpl-git-commit.yaml:346-350 substitutes today (__TEAM_NAME__, __SERVICE_NAME__, __IMAGE_TAG__, __PORT__, __HOST__), all digit-free. It does not catch one containing a digit: in __REGION1__ the match needs __ after __REGION, finds 1, and fails. A future template rendering repository: ghcr.io/mctlhq/__REGION1__-__SERVICE_NAME__ therefore passes the guard, compares unequal at line 203, and lands in the silent BUILDS=false this guard exists to prevent — an onboard that builds nothing and then bumps image.tag to a tag nothing pushed.

__[A-Z][A-Z0-9_]*__ closes it. Nothing upstream filters digits out first: values-images.sh:79's value class is [A-Za-z0-9._\/:@-], which admits both digits and _, so such a value reaches this line intact.

Fix this →

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No P1/P2 findings (3 P3). Good to merge.

The PR content is unchanged from the approved 0c8ce302 except for the merge of main (bdc83a67) and one fix (ceaf89fb). git diff 0c8ce302 ceaf89fb on check-image-name.sh, values-images.sh and image-names.txt is empty, so this review is about the merge resolution and the fix.

agy's P1 on bdc83a67 was real, and ceaf89fb fixes it correctly. Main had converted tpl-validate-tenant's validate from {{inputs.parameters.*}} interpolation to a nine-entry env: block; the merge left the PR's PARAM_CONTAINER_REGISTRY in a second env: after envFrom:, and sigs.k8s.io/yaml is last-wins, so the first nine would have been dropped and every deploy would have failed at invalid team:. At ceaf89fb the key is merged into the existing block (tpl-validate-tenant.yaml:78-79), and the file has exactly one env: and one envFrom: per script.

Merge resolution checked against each of main's refactors in these files, not just for conflict markers.

  • tenant_name_reserved: main replaced the for reserved in ... loop with a # BEGIN/END tenant-ns-guard block that tests/test_wft_create_tenant_existing.py:174 asserts is byte-identical across six copies. platform is added to all six, identically, inside the guard region. platform is not a tenant directory and is not in that test's MUST_ACCEPT, and platform-x was already refused by the platform-* pattern, so nothing it pins regresses. The PR's own assertion moved with it — it now runs the extracted guard rather than parsing the retired loop line.
  • validate's env conversion: the PR's step 3b reads $PARAM_CONTAINER_REGISTRY the same way main's code reads its nine, and interpolates nothing into the script body (test_shell_param_injection.py passes).
  • tpl-git-commit's template selection: main kept it at :335-342, and the PR's onboard mirror is still line-for-line the same three branches. Main's substitution set is now __TEAM_NAME__/__SERVICE_NAME__/__IMAGE_TAG__/__PORT__/__HOST__; the PR substitutes the two that can appear in a repository: value and refuses any image still carrying a placeholder.
  • wft-deploy-service: main added write-secrets-handoff and put it in commit's dependencies; build-image keeps legacy dependencies, which treats Skipped as satisfied, so the new when on builds composes. team_name/component_name are present on the build task and in the dispatch payload.
  • No new direct dispatcher of build-image.yaml arrived with main: erpact-images.yaml is deliberately separate and does not push into ghcr.io/mctlhq, and release-deploy.yaml:58 is still the only uses:.

This verdict is verified, not read. Validate Manifests on this commit (run 38162731557, job 114538850814, head ceaf89fb) logs OK: 33 fixture cases, 28 existing services, 9 referenced platform images, 31 real values files read. The real-tree sweep matters more here than in earlier rounds: main rewrote labs/claude-remote/values.yaml (216 lines), both mctl-telegram values files and the two mctl-agents-worker-* files, and the reader still agrees with yaml.safe_load on all 31. ubuntu-latest means mawk, so the awk-portability question stays settled.

P3s, all documentation drift created by the merge plus one carried-over nit. Main deleted service-templates/openclaw/ and all three services/*/openclaw/ directories, so: the shared openclaw rationale in image-names.txt:78-88 argues from three services and a template that no longer exist, and the line has collapsed to behaving like reserved; the openclaw template's mctl-openclaw example in tpl-validate-tenant.yaml:170 and wft-deploy-service.yaml:133 is now the one case where the merge changed behaviour (that template name falls through to default, so it builds), while the code is unaffected precisely because the decision reads the rendered values rather than the template name — the test already moved to a synthetic runs-fixed fixture. And the placeholder guard at :201 is still __[A-Z][A-Z_]*__, which a digit-bearing placeholder escapes into the silent BUILDS=false it exists to prevent.

@mashkovd

Copy link
Copy Markdown
Member Author

Triage before merge (head ceaf89f):

  • agy P1 on ceaf89f ("team_name / component_name not declared as build-image.yaml inputs") is a false positive. At ceaf89f both inputs are declared under workflow_dispatch and under workflow_call (inputs: repo, git_ref, image_name, image_tag, dockerfile_path, team_name, component_name, build_id), and the run-name and the "Check image name" steps read them.
  • agy/Claude P1 on bdc83a6 (duplicate env: in validate) is fixed in ceaf89f; Claude approved ceaf89f with 0 P1/P2.
  • The 3 P3s (openclaw rationale in image-names.txt and the tpl-validate-tenant/wft-deploy-service comments now describe a removed template; the placeholder regex excludes digits) are deferred to a follow-up.
  • Companion: fix(operations): reserve the tenant name platform mctl-api#565 mirrors the platform reservation in reserved_tenant.go.

@mashkovd
mashkovd merged commit 91355c0 into main Oct 11, 2026
38 checks passed
@mashkovd
mashkovd deleted the fix/reserve-image-names branch October 11, 2026 18:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants