PR #7015 · branch registry-container-entity · for the Monday review with Cole.
Read top-to-bottom. Every claim below is anchored to file:line so you can open
it live on the call.
The PR says 78 files. Say this first, before anyone forms an opinion:
GENERATED 33 files, +15,756 ← entc + gqlgen output, nobody reviews
HAND-WRITTEN 45 files, +4,376 ← the actual feature
78% of the diff is machine-written. Adding one ent entity (ContainerRegistry)
makes entc emit the whole CRUD surface, predicates, mutation types, GraphQL
plumbing, and migration table. That cost is identical for any new entity in
this codebase — it says nothing about the size of this feature.
.gitattributes marks those paths linguist-generated=true, so GitHub
collapses them by default and excludes them from language stats. Ask Cole to
review with that on: he sees 45 files.
The hand-written 4,376 lines:
| Area | Files | Lines | Where to spend review time |
|---|---|---|---|
pkg/registrypublish — core library |
8 | +1,502 | Yes — the real logic |
| GraphQL resolvers + tests | 6 | +1,112 | Yes — authz surface |
| Web UI | 7 | +752 | Skim |
| Workflow wiring | 5 | +424 | Yes — the injection seams |
| Platform seams (policyverify, subscription) | 3 | +217 | Yes — small but load-bearing |
| Schema, deps, repo config | 8 | +208 | Skim |
| ent schema (source of the generated code) | 1 | +135 | Yes — read this first |
| Tenancy + authz plumbing | 7 | +26 | Explain and move on |
That last row is the answer to "why did you touch lint_systemviewer.go /
closure_test.go / systemviewer_elimination_test.go?" — seven files,
twenty-six lines, almost all one-line counter updates. They are not feature
work. This repo is deliberately built so you cannot add a tenant-owned entity
without updating them; CI fails otherwise. Their presence is evidence the
feature was integrated correctly.
Cole's requirement: all generated attestations must be stored in and retrievable from JFrog Artifactory.
- Store — when an attestation is ingested, the platform pushes the DSSE
envelope into the customer's registry, next to the image it describes, in
cosign's layout: tag
sha256-<image-digest>.att, layer media typeapplication/vnd.dsse.envelope.v1+json. - Retrieve + verify —
verifyAttestationFromRegistrypulls the envelope back out of the registry and evaluates it against the product's policy. Nothing is re-stored; only the verdict is persisted. The registry is the source of truth. - Gate — a registry can be marked
require_policy_pass, in which case the platform refuses to publish an attestation that did not pass its product's policy. Fail-closed.
Interop matters: because the layout is cosign's, cosign verify-attestation,
crane, and JFrog's own UI can read what we write. We have no cosign
dependency — the library is Google's go-containerregistry. We implement the
convention, exactly as witness attach attestation does.
judge-api/ent/schema/container_registry.go (+135) — the only hand-written
ent file, and the source of all 15.7k generated lines.
Two mixins (:31-38):
// Provides the required, immutable tenant edge plus the tenant
// isolation privacy rules (query filtering + mutation ownership),
// so this schema needs no Policy() of its own.
TenantMixin{},
MetaDataMixin{},TenantMixin is the whole tenancy story in one embed. It adds a required,
immutable tenant edge and a full privacy policy (ent/schema/tenant_mixin.go:15-41):
mutations go through DenyIfNoViewer → DenyMutationIfTenantSuspended → AllowMutationIfOwnerTenant → DenyMutationIfNotAccessibleTenant; queries through
DenyIfNoViewer → DenyIfNoTenants → FilterAccessibleTenants.
15 fields. The ones worth naming on the call:
| Field | Notes |
|---|---|
url |
bare host, no scheme — SSRF-validated on write |
repo_key |
JFrog-style path segment; a security boundary, see §5 |
password, token |
Sensitive() — omitted from String() and never returned by GraphQL |
predicate_filters |
JSON []string, which predicate types to publish |
sync_enabled |
opt-in, default false |
require_policy_pass |
the fail-closed gate, default false |
revoked |
soft delete |
One partial unique index (:123-131):
index.Fields("name").Edges("tenant").Unique().
Annotations(entsql.IndexWhere("NOT revoked"))Unique on (name, tenant) only over non-revoked rows — a disconnected
registry keeps its history without blocking the name for re-registration.
Known, documented risk to raise yourself: the schema comment at :22-25
states credentials are plaintext at rest, relying on Postgres TDE +
KMS-managed disk encryption, matching how ComplianceIntegration already works.
That's a deliberate risk acceptance consistent with the existing codebase — say
it before Cole finds it.
One schema file fans out through two generators:
- entc (
judge-api/ent/entc.go) →ent/generated/** - tenantgen (
judge-api/ent/internal/tenantgen/, an in-tree entc extension) → the tenant-isolation files
Everything regenerates with jade generate --go.
| File | Hand-written? | Generated by | Why it changed |
|---|---|---|---|
ent/schema/container_registry.go |
YES | — | the source of truth |
ent/generated/** (18 files) |
no | entc | new entity → full CRUD/GraphQL surface |
tenantinvariant/register_gen.go + triggers_gen.sql |
no | tenantgen | has an M2O tenant edge |
rule/tenant_filter_gen.go |
no | tenantgen | query-side tenant filter |
tenantscope/constructors_gen.go |
no | tenantgen | has TenantMixin |
tenantclosure/closure.go |
YES (1 line) | — | AST completeness test fails otherwise |
tenantclosure/closure_test.go |
YES (1 line) | — | pinned count 116 → 117 |
rule/tenant_mutation_accessor_gate_test.go |
YES (1 line) | — | mutation-accessor reflection check |
auth/systemviewer_elimination_test.go |
YES (1 line) | — | 29 → 32 — see §9 |
pkg/judgeclient/generated.go |
no | genqlient | +21 lines, regenerated from the schema |
Answers to your specific questions:
ent/generated/containerregistry/containerregistry.go — machine-generated
(line 1 literally says // Code generated by ent, DO NOT EDIT.). The
containerregistry/ sub-package is metadata + predicates: column-name
constants (FieldPassword = "password"), enum validators, order-by builders,
and typed predicates (containerregistry.HasTenantWith(...)). The
ent/generated/containerregistry_*.go files in the parent package hold the
runtime: the struct and the Create/Update/Delete/Query builders. The split
exists to break an import cycle — builders import the sub-package, never the
reverse. Sub-package = "how to name and filter columns"; parent = "how to read
and write rows."
tenantclosure/closure_test.go — you asked why the SBOM comment was
removed. The +N suffix is a rolling annotation naming only the most recent
PR's delta, not cumulative history:
- ClassTenantMixin: 116, // +2: SBOMComponentInterval + SBOMScan (...)
+ ClassTenantMixin: 117, // +1: ContainerRegistry (registry-publish connector)
Nothing was lost — we replaced the previous PR's note with ours, as the file's convention requires. "Tenant closure" is the transitive set of rows belonging to one tenant, and this manifest is the single source of truth for tenant export (#6176) and GDPR erasure (#5503). An AST test fails the build if a schema is added without a manifest entry, so a new entity can't silently escape either.
tenantinvariant/register_gen.go + triggers_gen.sql — a "tenant
invariant" is one rule: once a row's tenant FK is non-NULL it can never
change. Two enforcement layers: an ent mutation hook (covers ent traffic) and
Postgres triggers (catch raw SQL the hook can't see). Our 22 lines add
judge_tenant_immutable__container_registries() + a BEFORE UPDATE trigger
that raises on both NULL-ing and cross-tenant reassignment, while still allowing
NULL → non-NULL for backfills. From #4937 — closes the raw-SQL re-tenanting
bypass.
rule/tenant_filter_gen.go — this is what makes TenantMixin's query
policy real. Our three lines:
case *ent.ContainerRegistryQuery:
q.Where(containerregistry.HasTenantWith(tenant.Or(orPredicates...)))
return trueEvery read is silently rewritten to WHERE tenant IN (viewer's tenants).
tenantscope.NewContainerRegistryCreate — you asked what this is:
func NewContainerRegistryCreate(c *generated.ContainerRegistryClient, scope TenantScope) *generated.ContainerRegistryCreate {
return c.Create().SetTenantID(scope.TenantID())
}TenantScope is an opaque struct with unexported fields, obtainable only
via tenantscope.New(ctx, tenantID) (verifies the viewer is a member) or
NewAsAdmin (requires platform admin). Raw .SetTenantID is banned by a
forbidigo rule (.golangci.yml:272) because a function taking a bare
uuid.UUID "has no way to express 'the caller has verified the viewer can act
on behalf of this tenant'" — two real in-house CVEs (#4940 cross-tenant
attestation migration, #4941 cross-tenant comment add) had exactly that shape.
Wrapping the UUID in a type only producible by a verification step makes
cross-tenant writes compile-impossible. This PR adds no new forbidigo
exclusions — worth pointing out.
This is the spine of the walkthrough. Follow it in order.
cilock run --enable-archivista
│ uploads DSSE to Archivista (no curl, user's login session)
▼
Archivista stores envelope → emits pub/sub message {gitoid, apiToken}
▼
cmd/server/cmd/api.go:2370 ProcessMessage
▼
pkg/subscription/subscription.go:114 GenerateWorkflows → processAttestation
│ NEW: initRegistryPublishWorkflow (:1322)
│ ├─ row mode: registrypublish.Matcher() under a 2-min timeout
│ └─ env fallback: only when row mode isn't wired at all
▼ returns WorkflowInfo{Name:"RegistryPublishWorkflow", Input:…}
api.go:2410 ScheduleWorkflow (durabletask)
▼
pkg/workflow/workflows/registrypublish/publish.go:42 PublishWorkflow
├─ activity 1: ArchivistaRetrieveActivity → envelope returned INLINE
└─ activity 2: RegistryPublishActivity
▼
pkg/workflow/activities/registrypublish/publish.go:42
├─ registrypublish.LoadAuth(ctx, registryID) ← creds loaded BY ID here
└─ registrypublish.PublishEnvelope(...)
▼
JFrog: <repo>:sha256-<image-digest>.att
subscription.go fails closed (:1326-1336): if the matcher errors, we
return nil and publish nothing. Falling back to env credentials there would
bypass predicate filters, require_policy_pass, and revocation — and could ship
global credentials to a host named by tenant-controlled attestation subjects.
Why the envelope is returned inline (activities/archivista/retrieve.go:44-47):
an earlier design wrote /tmp/<sha> and broke when durabletask dispatched the
next activity to a different pod.
Retry / idempotency: 3 attempts, 2s initial, 2× backoff, 30s cap
(pkg/workflow/retry.go:22-27). Retry safety comes from the push itself —
PublishEnvelope skips a layer digest that's already present, so a replay is a
no-op.
pkg/workflow/registration/registrypublish.go installs two closures:
SetMatcher and SetAuthLoader. Expect Cole to ask why.
The depguard rule activities-use-graphql-not-ent (.golangci.yml:191-215)
forbids anything under pkg/workflow/activities/** from importing
ent/generated, because direct ent access bypasses the tenant privacy filter.
The normal escape hatch — read over GraphQL with a tenant-scoped token —
doesn't work here, because the credential columns are Sensitive() and
GraphQL never returns them. So the registration layer (which legitimately holds
deps.EntClient and is outside that glob) captures the client in a closure and
the activity stays ent-free. Precedent in the same file: cilock.SetPlatformRootsFn,
byorextract.SetPersistDraftFn.
Why deps.ArchivistaStore — your question. The policy gate must (a)
download the just-ingested envelope to seed an in-memory source and (b) hand a
Store to policyverify.RunSync. Neither can live in the activity (ent-barred),
and the gate is fundamentally an ent query chain. nil disables the gate; for a
require_policy_pass registry that means no publish at all — fail-closed.
Why credentials load by ID at push time, not from workflow input: durabletask
persists workflow and activity inputs to the orchestration history store.
Credentials in the input would be written to durable storage and replayed. Loading
by ID also re-checks Revoked(false), so a queued or retried workflow stops
publishing the moment the owner disconnects the registry.
AttestationPassesPolicy (registration/registrypublish.go:143), fail-closed at
every step:
git attestor subject .../git/v0.1/remote:<url>
▼ (both raw and .git-stripped forms)
ProductRepository.url ──► Product ──► PolicyBinding ──► RunSync
(all scoped to tenant.ID(tenantID))
Two subtleties that were real bugs:
- It downloads this envelope by gitoid and seeds a
MemorySource, rather than querying Archivista's subject-digest index — the index lags the ingest-time dispatch, which made the gate fail-closed on attestations that should pass. RunSyncruns undersysCtx, not the raw ingest ctx — the ingest subscriber carries no viewer, so the binding/policy load was privacy-denied.
The biggest hand-written chunk (+1,502). Three files.
TargetsFromSubjects (:151) — turns attestation subjects into publish
targets. Handles two conventions:
- cosign/SLSA:
subject.Nameis the image ref, digest is the image digest. - witness/cilock: the docker attestor emits URI-named subjects —
.../docker/v0.1/imagereference:<ref>and.../imagedigest:<digest>.
Deliberate constraint: it pairs refs to a digest only when there is exactly
one digest (:183). One docker build emits one digest and N refs (multiple
-t tags), so fan-out is correct; with several distinct digests, flat subjects
can't safely pair repo↔digest, so those are dropped rather than risk attaching
an attestation to the wrong image.
PublishEnvelope (:299) — read-append-write:
targetTag→name.NewRepository(...).Tag("sha256-<hex>.att")tagLocks.lock(tag)— in-process serializationremote.Image(tag)— if a layer with our digest already exists, returnAlreadyPresent(idempotency); 404 → start fromempty.Imagemutate.Append(base, mutate.Addendum{Layer: static.NewLayer(json, DsseMediaType), ...})remote.Write(tag, img), then loop to re-verify survival (max 4 attempts)
FetchEnvelopes (:375) — the read side. Three caps, because a registry is
external input: 256 layers, 64 MiB per layer, and 256 MiB aggregate — the
first two alone multiply out to 16 GiB of live bytes, enough to OOM a
multi-tenant service. readLayerCapped errors rather than truncates: a
truncated DSSE envelope must never be treated as the attestation's content.
What we write (the cosign layout):
| Element | Value | Whose convention |
|---|---|---|
| tag | sha256-<image-digest>.att |
cosign |
| layer media type | application/vnd.dsse.envelope.v1+json |
cosign |
| annotation | dev.witnessproject.witness/signature: "" |
witness |
| manifest/config | OCIManifestSchema1 / OCIConfigJSON |
OCI |
Reference: https://pkg.go.dev/github.com/google/go-containerregistry
| API | Where | What it does |
|---|---|---|
name.NewRepository, .Tag() |
:359, :363 |
parse repo, derive the .att tag |
name.ParseReference |
:262 |
parse a subject name to extract its repo |
remote.Image |
:315, :381 |
GET the manifest at the tag (404 = nothing yet) |
empty.Image |
:522 |
zero-layer base |
mutate.MediaType / ConfigMediaType |
:522-523 |
stamp OCI (not Docker) types |
static.NewLayer |
:334 |
wrap in-memory bytes as a layer — no tarball/gzip |
mutate.Append |
:333 |
functional append, returns a new image |
remote.Write |
:341 |
upload blobs, PUT the manifest |
authn.Basic / Bearer / Anonymous |
:510-514 |
credential providers |
Note remoteOptions' default branch (:513): a target with no stored
credentials gets authn.Anonymous, never the ambient keychain — which would
hand the server's own docker credentials to whatever host a tenant-controlled
subject names.
MatchTargets (:61) — pure and testable; the ent-backed matcher resolves
the registry list and delegates the decision here.
registryMatchesRepository (:127) — the security-relevant one. The
repository string comes from the attestation subject, i.e. from whoever
produced the evidence. Host-only matching would let a subject naming
host/some-other-namespace/img drive this registry's stored credentials at a
namespace the tenant never registered. So when repo_key is set it's a
boundary: HasPrefix(repository, url+"/"+repoKey+"/"). Empty repo_key
keeps host-only matching — correct for Docker Hub and ghcr, which have no such
concept.
Registry URLs are tenant-controlled input. Without this, a tenant admin could point a "registry" at loopback, RFC1918, link-local (cloud metadata), or CGNAT and drive server-side requests into the internal network.
Two layers, both fail-closed unless REGISTRY_PUBLISH_ALLOW_PRIVATE_HOSTS:
ValidateRegistryURL(:39) — registration-time check. Explicitly the non-authoritative layer; it exists to give the operator a clear error early.registryTransport(:76) — the authoritative one. Anet.Dialer.Controlhook checks the resolved address of every connection, which also defeats DNS rebinding. Andt.Proxy = nil(:91) —http.DefaultTransportcarriesProxyFromEnvironment, and withHTTPS_PROXYset the dial would target the proxy while the proxy resolves and connects to the tenant-supplied host, bypassing the whole guard.
judge-api/container_registry_verify.go. This is the file to walk when Cole asks
how retrieval is proven.
:91 DownloadDSSE(gitoid) ← platform's own copy: subjects only
:100 MatchTargets ← which repo + digest
:120 FetchEnvelopes(target, auth) ← THE RETRIEVE, straight from JFrog
:129 MemorySource ← seeded ONLY with the requested envelope
:153 policyverify.RunSync(..., WithCollectionSource(memSource))
We did not write a second verifier. RunSync is the platform's existing
policy-evaluation entrypoint (pkg/policyverify/sync.go:75, dating to
2026-04-29, ~2.5 months before this feature), delegating to witness's verifier
in pkg/workflow/activities/cilock/verify.go (2024-08-01). Our total
contribution inside it is 38 lines — runConfig, RunOption,
WithCollectionSource — threaded into a variable that already existed:
var collectionSource source.Sourcer = NewEntSource(client, store, granter, bind.Edges.Tenant.ID)
if cfg.collectionSource != nil { collectionSource = cfg.collectionSource }So the substitution is where the evidence comes from, not how it's judged.
Same policy engine, same signature checks, same verdict row — different input.
There are three RunSync callers now: the original VerifyComplianceSync
resolver, our registry verify, and our publish gate.
Why only the requested envelope is loaded (:110-116) — this was a real
soundness bug. The .att tag can hold several attestations for the same image,
and witness passes a step as soon as any collection satisfies it
(policy/step.go: if len(r.Passed) > 0 { pass = true }). Loading siblings
would let a different, trusted envelope produce PASSED for an attestation that
is itself untrusted. Zero loaded → hard error saying the registry copy is
missing or altered.
envelopesEquivalent (:161) compares payload type + payload bytes + at
least one matching (keyID, signature) pair. Raw-JSON byte equality is
deliberately not used, because the publish path re-marshals the envelope —
field ordering can differ while the signed content is identical.
Empirical proof to offer Cole: an attestation present in Archivista but absent from JFrog made this path error; the ones present in JFrog passed. That's the difference between "we verified it" and "we verified it from the registry."
Six mutations. Note the ID casing — registryID, bindingID, tenantID —
matching the repo convention (tenantID appears 47×, productID 5×):
| Mutation | Impl | Scope |
|---|---|---|
registerContainerRegistry |
container_registry_resolvers.go:37 |
SupplyChainWrite |
updateContainerRegistry |
:109 |
SupplyChainWrite |
setContainerRegistrySyncEnabled |
:~185 |
SupplyChainWrite |
deleteContainerRegistry |
:~210 |
SupplyChainAdmin (drops a stored credential) |
attachAttestationToRegistry |
container_registry_attach.go:41 |
SupplyChainWrite |
verifyAttestationFromRegistry |
container_registry_verify.go:37 |
AttestationVerify |
Registered in internal/auth/graphql_scope.go:391-401.
Two independent layers — worth stating explicitly, they're often confused:
- GraphQL scope — token-level. Does this API token carry
SupplyChainWrite? - ent privacy — row-level.
TenantMixin's policy +tenant_filter_gen.gorewrite every query to the viewer's tenants.
Plus a resolver-level check, requireTenantOwnerOrAdminFor, and adminctx.AsAdmin
used to load a row across the tenant filter after authorization has been
established (not instead of it).
These are the ones worth opening on a security-focused review:
| Test | What it proves |
|---|---|
TestContainerRegistry_TenantIsolation |
tenant A cannot see B's registries |
TestContainerRegistry_CredentialsAreSensitive |
credentials never leave via the API |
TestRegisterContainerRegistry_AuthzCrossTenant |
cross-tenant create is refused |
TestAttachAttestationToRegistry_Authz |
cross-tenant attach is refused |
TestUpdateContainerRegistryClearsCredentialsOnRetarget |
moving a URL drops the old secret |
TestContainerRegistry_UniqueNamePerTenant |
the partial unique index behaves |
TestKeyedMutexEvictsEntries / …WithWaiter |
the publish lock doesn't leak |
TestRegistryTransportDoesNotProxy |
SSRF guard can't be proxied around |
internal/configuration/envvars.go (+9) — Registry is the canonical,
single-source list of every environment variable judge-api consumes; the daemon
and the secrets UI read it at runtime. Sensitive: true marks a value to be
masked in that UI; Category groups it. Our 7 entries are the
REGISTRY_PUBLISH_* set for the env-var POC fallback — the mode used before
per-tenant rows existed. Normal operation uses registry rows; the env mode is
only reached when row mode isn't wired at all.
pkg/judgeclient/generated.go (+21) — genqlient output, regenerated from
the schema. Not hand-written.
jade/cmd/lint_systemviewer.go (+1) — an allowlist for the SystemViewer
ratchet lint: "judge-api/pkg/workflow/registration/registrypublish.go": 3. See §9.
.gitattributes (+15) — linguist-generated=true tells GitHub two things:
collapse these files in the diff view, and exclude them from repository language
statistics. Hand-written schema sources (mutations.graphql, *_extended.graphql,
policy_*.graphql) are deliberately not listed. This is what turns a
78-file PR into a 45-file review.
web/.../ContainerRegistriesCard.tsx (+490) — Settings → Connectors card.
Queries the tenant's rows, add-form, per-row sync and require-policy toggles,
delete, and the inline predicate-filter editor (presets + custom add + removable
chips) so filters can be changed without disconnecting and re-adding.
One gotcha worth knowing because it cost real debugging time: ToggleInput
must be wrapped in an ancestor <label>. Without it the toggle renders,
looks live, and does nothing — no network request at all (issue #6760). That was
the "toggles don't work" symptom.
Going in with these is much stronger than being caught by them.
internal/auth/systemviewer_elimination_test.go:36 — SystemViewerSitesRemaining
went 29 → 32. That constant is the countdown for an epic to delete
SystemViewer entirely, and the file says:
"Every mint-site migration PR MUST LOWER it… It can therefore only ever go DOWN."
Its failure message names the alternative: "mint a per-tenant scoped token via
internal/auth/internalgrant instead."
Our 3 sites are all in pkg/workflow/registration/registrypublish.go — :49
matcher, :112 auth loader, :147 policy gate. The justification: the ingest
subscriber context carries no viewer, and the credential columns are
Sensitive() so GraphQL can't serve them. We compensate with explicit
tenant.ID(tenantID) predicates on every query.
The weak spot: internalgrant — the prescribed alternative — is already used
in this same feature (RunSync takes a *internalgrant.Granter). So "we
couldn't" isn't quite true; "we didn't attempt it" is. Offer it as a follow-up.
pkg/policyverify/sync.go:684-692 — persistEvaluation now saves under
privacy.DecisionContext(ctx, privacy.Allow). The Status mutation policy
admits only a SystemViewer, so a user-triggered verify succeeded at
verification but was denied at persist. The fix is justified by the
authorization already performed upstream, but it widens the bypass for every
RunSync caller, not just ours. Narrowing it to the registry-verify path is a
small, clean follow-up.
Two Judge replicas can both read manifest M0, each append their own layer, each observe their own write succeed, and the later write silently drops the earlier layer. No error anywhere.
This is not our bug to have invented — it's inherent to the cosign tag layout. The OCI project says so about exactly this mechanism:
"The client requirement to manage the fallback tag is subject to race conditions since the existing content of the fallback tag must first be queried to add the new entry" — https://opencontainers.org/posts/blog/2024-03-13-image-and-distribution-1-1/
OCI tags have no compare-and-swap — no If-Match on a manifest PUT — so
read-append-write cannot be made atomic client-side. witness attach attestation
has the same exposure.
Mitigation shipped: publishes to one tag are serialized within a process
(keyedMutex, reference-counted so it can't leak). Single-replica deployments —
including the demo standalone — are fully covered.
The real fix is the OCI Referrers API (OCI 1.1): each attestation is pushed
as its own manifest with a subject field pointing at the image digest, so every
push is digest-addressed and nothing can be overwritten. JFrog supports it from
Artifactory 7.90.1
(https://jfrog.com/help/r/jfrog-artifactory-documentation/use-referrers-rest-api-to-discover-oci-references).
Interim option: a pg_advisory_lock keyed on the tag, which closes it for all
Judge-originated writes.
If CI is red, check whether it's TestTriggerComplianceSync_RejectsConcurrentSyncs.
That test fails identically on origin/main — a compliance-sync test with a
30-minute staleness boundary. Verified by running it in a worktree at origin/main.
Scripts are in the rahulxf-swf repo under demo/ (private):
export ADMIN_PASS='…'
./demo/01-publish.sh v6 # build → attest → upload → platform pushes to JFrog
./demo/02-verify.sh v6 # platform verify + offline crane/cilock verify
./demo/03-policy-gate.sh v7 # PASS publishes, BLOCKED does notThe gate demo is the strongest single artifact: same registry, same policy, one build with a git subject and one without — both images land in JFrog (docker pushes those), but only the attestation that passed its product's policy is published.
Vocabulary to keep straight on the call, because it caused confusion during
development: v6/v7 are image tags, pushed by docker buildx, never
gated. sha256-<digest>.att is the attestation tag, published by the
platform, and it's the only thing the gate controls. Seeing an image tag in
JFrog tells you nothing about whether its attestation was published.