Skip to content

feat(stack): fail through slim image and native artifact mirrors - #6661

Open
avallete wants to merge 2 commits into
developfrom
avallete/slim-artifact-mirrors
Open

avallete wants to merge 2 commits into
developfrom
avallete/slim-artifact-mirrors

Conversation

@avallete

@avallete avallete commented Sep 17, 2026

Copy link
Copy Markdown
Member

Summary

Prefer ECR then GHCR, keep digest pins, and pair checksum with archive per host.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤖 AI Review

Adjudicated all 20 reported findings into 19 deduplicated entries. Seventeen are confirmed, one is refuted, and one remains uncertain. The main confirmed risks are regressions for custom registry users and hard-coded OCI authentication; the shared inspection-error misclassification was reported by both reviewers.

Findings

Severity Location Category Sources Claim
🟠 MAJOR packages/stack/src/model/SlimArtifactMirrors.ts:122 compatibility claude Applying the existing image-registry override to native artifacts removes the GitHub Releases fallback and requires the custom registry to carry the newly introduced OCI-native artifacts, breaking existing native users whose mirror only contains images.
🟠 MAJOR packages/stack/src/preparation/SlimNativeOci.ts:32 compatibility codex Custom native registries are not resolved through the registry authentication protocol: the client always requests a hard-coded HTTPS /token endpoint before accessing the registry.
🟡 MINOR packages/stack/src/preparation/SlimServicesSource.ts:386 performance claude Every cache miss resolves the upstream checksum twice because ArtifactStore calls checksum before materialize, while materialize ignores that result and fetches candidate metadata again.
🟡 MINOR packages/stack/src/preparation/RuntimeArtifacts.ts:170 error-classification claude+codex Image inspection failures are incorrectly converted into ContainerPullError even though no pull may have occurred.
🟡 MINOR packages/stack/src/preparation/SlimNativeOci.ts:160 integrity claude The OCI archive is not verified against its manifest descriptor digest; archiveDigest is returned but discarded, and verification uses only SHA256SUMS fetched from the same candidate.
🟡 MINOR packages/stack/src/runtime/resolve-container-image.ts:31 error-handling claude When every image pull candidate fails, the resolver discards all failures except the final candidate's error.
🟡 MINOR packages/stack/src/runtime/resolve-container-image.ts:14 test-coverage claude The new container-image resolver has no test covering first-candidate pull failure followed by second-candidate success, or exhaustion of all candidates.
🟡 MINOR packages/stack/src/preparation/SlimServicesSource.ts:216 configuration claude Stack mirror selection bypasses the provided ConfigProvider and project dotenv values by reading the ambient environment directly.
🟡 MINOR packages/stack/src/preparation/SlimNativeOci.ts:6 maintainability claude Four new exports are unused outside SlimNativeOci and are eligible for unused-export failures in the repository's Knip check.
🟡 MINOR apps/cli/src/command-internal/docker-registry.ts:67 behavior-change claude A custom registry override now rewrites slim images to a single custom-registry candidate, changing the previously shipped behavior in which slim references bypassed the override.
🟡 MINOR apps/cli/src/shared/services/slim-images.ts:149 test-coverage claude The newly accepted ECR slim-image namespace is not covered by CLI tests that protect slim-specific runtime specifications.
🟡 MINOR .github/workflows/mirror-slim-image.yml:190 observability claude Best-effort native mirroring relies on a stated daily drift audit, but no such audit is defined in this repository.
🟡 MINOR packages/stack/src/preparation/SlimNativeOci.ts:60 error-handling codex A malformed OCI title annotation can throw a defect and bypass candidate failover.
⚪ NIT packages/stack/src/preparation/SlimServicesSource.ts:243 error-handling claude Native candidate aggregation retains only each failure's message and drops the underlying causes and structured context.
⚪ NIT packages/stack/src/runtime/resolve-container-image.ts:37 dead-code claude The empty-candidate fallback is unreachable and would incorrectly label an unchecked image as cached if reached.
⚪ NIT packages/stack/src/model/SlimArtifactMirrors.ts:26 dead-code claude The detected claude host hint never affects candidate ordering and is behaviorally identical to default.
⚪ NIT apps/cli/src/command-internal/docker-registry.ts:70 style claude Slim registry option construction is duplicated, and overrideValue is evaluated twice in each copy.
⚪ NIT apps/cli/src/commands/db/shared/pgdelta.seam.layer.ts:263 documentation claude The comment saying slim references never pass through a registry mirror is now false.

Findings outside the diff

  • ⚪ NIT apps/cli/src/commands/db/shared/pgdelta.seam.layer.ts:263 — The comment saying slim references never pass through a registry mirror is now false.
Refuted findings (kept for transparency, not posted as review comments)
  • packages/stack/src/preparation/SlimServicesSource.ts:390 (correctness): Reusing compressedPath across candidate attempts can append a later download to a partial earlier archive because no explicit write flag is supplied.
    Refuted: Effect FileSystem.sink defaults to write/truncate semantics. The integration test at slim-services.integration.test.ts:575-635 exercises this exact reuse: OCI writes the archive then fails its stale checksum, a later GitHub attempt writes the same path, and the resulting archive extracts successfully.

Stats

Claude findings: 17 · Codex findings: 3 · Confirmed: 17 · Refuted: 1 · Uncertain: 1


Models: claude-opus-5 + gpt-5.6-sol · Trigger: auto · Workflow run

This review runs once per PR. A maintainer can request another with a /ai-review comment.

Comment on lines +122 to +125
if (mapped.kind === "prefix") {
const host = mapped.value.replace(/\/supabase\/cli\/$/u, "").replace(/\/$/u, "");
return [oci(host)];
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 MAJOR · compatibility · source: claude

Applying the existing image-registry override to native artifacts removes the GitHub Releases fallback and requires the custom registry to carry the newly introduced OCI-native artifacts, breaking existing native users whose mirror only contains images.

Evidence: packages/stack/src/model/SlimArtifactMirrors.ts:120-125 returns only oci(host) for a prefix override. The pre-PR path in pr.diff always fetched native artifacts from their GitHub release URLs.

Suggested fix: Use a separate native-artifact override, or retain GitHub as a fallback unless users explicitly request registry-only native fetching.

return `https://ghcr.io/token?service=ghcr.io&scope=repository:${repository}:pull`;
if (registry === "public.ecr.aws")
return `https://public.ecr.aws/token/?service=public.ecr.aws&scope=repository:${repository}:pull`;
return `https://${registry}/token?service=${registry}&scope=repository:${repository}:pull`;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 MAJOR · compatibility · source: codex

Custom native registries are not resolved through the registry authentication protocol: the client always requests a hard-coded HTTPS /token endpoint before accessing the registry.

Evidence: packages/stack/src/preparation/SlimNativeOci.ts:27-32 constructs https:///token, and lines 121-123 require that token before requesting the manifest. packages/stack/src/model/SlimArtifactMirrors.ts:122-125 supplies custom registries as the sole candidate.

Suggested fix: Request the V2 resource anonymously first, then parse and follow its WWW-Authenticate challenge using configured credentials and transport settings.

Comment on lines 386 to +436
@@ -307,65 +395,104 @@ export const makeSlimServicesSource = (
]);
return yield* Effect.gen(function* () {
const artifact = yield* resolveArtifact(request);
const manifestBytes = yield* fetchBytes(artifact.manifestUrl, fetchRequest);
const manifestText = new TextDecoder().decode(manifestBytes);
const manifest = yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))(
manifestText,
).pipe(
Effect.mapError(
(cause) =>
new StackPreparationError({
message: "Slim-services manifest is invalid",
cause,
}),
),
);
if (
typeof manifest !== "object" ||
manifest === null ||
!("service" in manifest) ||
!("version" in manifest) ||
!("target" in manifest) ||
manifest.service !== artifact.service ||
manifest.version !== artifact.version ||
manifest.target !== artifact.target
)
return yield* new StackPreparationError({
message: "Slim-services manifest does not match the catalog artifact",
service: artifact.service,
version: artifact.version,
target: artifact.target,
});
const entrypoint = "entrypoint" in manifest ? manifest.entrypoint : undefined;
const command = "cmd" in manifest ? manifest.cmd : undefined;
if (
(entrypoint !== undefined &&
(!Array.isArray(entrypoint) ||
!entrypoint.every((value) => typeof value === "string") ||
entrypoint.some(unsafeManifestCommand))) ||
(command !== undefined &&
(!Array.isArray(command) ||
!command.every((value) => typeof value === "string") ||
command.some(unsafeManifestCommand)))
)
return yield* new StackPreparationError({
message: "Slim-services manifest command is invalid",
service: artifact.service,
version: artifact.version,
});
yield* Effect.sync(() => onProgress?.("downloading")).pipe(
Effect.andThen(
downloadToFile(artifact.downloadUrl, compressedPath, fetchRequest, expectedSha256),
),
Effect.mapError(
(cause) =>
new StackPreparationError({
message: "Unable to download slim-services archive",
service: artifact.service,
version: artifact.version,
cause,
}),
),
const options = yield* candidateOptions;
const archiveName = `${artifact.assetName}.tar.zst`;
const selectedSha256 = yield* tryCandidates(
nativeArtifactCandidates(artifact, options),
(candidate) =>
Effect.gen(function* () {
const fetched =
candidate.kind === "github"
? {
manifestBytes: yield* fetchBytes(candidate.manifestUrl, fetchRequest),
checksumText: new TextDecoder().decode(
yield* fetchBytes(candidate.checksumUrl, fetchRequest),
),
download: (sha256: string) =>
downloadToFile(
candidate.downloadUrl,
compressedPath,
fetchRequest,
sha256,
),
}
: yield* fetchOciNativeTriplet(candidate).pipe(
Effect.provide(transport(fetchRequest)),
Effect.map((triplet) => ({
manifestBytes: triplet.manifestBytes,
checksumText: triplet.checksumText,
download: (sha256: string) =>
downloadToFile(
triplet.archiveUrl,
compressedPath,
fetchRequest,
sha256,
triplet.headers,
),
})),
);
// Pair checksum and archive on the same candidate so a stale first-host
// digest cannot reject a later host's matching bytes.
const sha256 = yield* parseChecksum(fetched.checksumText, archiveName);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 MINOR · performance · source: claude

Every cache miss resolves the upstream checksum twice because ArtifactStore calls checksum before materialize, while materialize ignores that result and fetches candidate metadata again.

Evidence: packages/stack/src/preparation/ArtifactStore.ts:694-705 calls source.checksum; packages/stack/src/preparation/SlimServicesSource.ts:386 ignores expectedSha256 and lines 398-436 repeat candidate metadata and checksum resolution.

Suggested fix: Cache checksum metadata between the two operations or let this source materialize without a preliminary checksum lookup.

Comment on lines +170 to +180
resolveAvailableContainerImage(engine, image, process.env, () =>
report("downloading"),
).pipe(
Effect.mapError(
(cause) =>
new ContainerPullError({
message: `Unable to pull container image ${image}`,
workload: workload.id,
cause,
}),
),
),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 MINOR · error-classification · source: claude+codex

Image inspection failures are incorrectly converted into ContainerPullError even though no pull may have occurred.

Evidence: packages/stack/src/runtime/resolve-container-image.ts:26-29 can fail from engine.inspectImage; packages/stack/src/preparation/RuntimeArtifacts.ts:170-180 maps every resolver failure to ContainerPullError with an unable-to-pull message.

Suggested fix: Preserve the operation phase and map inspection failures to ContainerEngineError, reserving ContainerPullError for exhausted pull attempts.

Comment on lines +160 to +161
archiveDigest: archive.digest,
archiveUrl: `https://${candidate.registry}/v2/${candidate.repository}/blobs/${archive.digest}`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 MINOR · integrity · source: claude

The OCI archive is not verified against its manifest descriptor digest; archiveDigest is returned but discarded, and verification uses only SHA256SUMS fetched from the same candidate.

Evidence: packages/stack/src/preparation/SlimNativeOci.ts:157-163 returns archiveDigest. packages/stack/src/preparation/SlimServicesSource.ts:419-432 omits it and lines 436 and 483 verify the archive only against checksumText.

Suggested fix: Require the descriptor digest to equal the parsed archive checksum and verify downloaded bytes against that digest.

Comment on lines +243 to +251
const failures: Array<string> = [];
for (const candidate of candidates) {
const result = yield* Effect.result(tryOne(candidate));
if (Result.isSuccess(result)) return result.success;
failures.push(result.failure.message);
}
return yield* new StackPreparationError({
message: `Unable to download from all slim artifact sources: ${failures.join("; ")}`,
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚪ NIT · error-handling · source: claude

Native candidate aggregation retains only each failure's message and drops the underlying causes and structured context.

Evidence: packages/stack/src/preparation/SlimServicesSource.ts:243-251 stores result.failure.message strings and creates a new StackPreparationError without cause. StackPreparationError supports cause and service/version context in public/Errors.ts:112-123.

Suggested fix: Attach the collected failures as the aggregate error's cause while retaining the combined user-facing message.

Comment on lines +37 to +40
if (lastError === undefined)
return yield* engine
.inspectImage(image)
.pipe(Effect.as({ image, outcome: "cached" as const }));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚪ NIT · dead-code · source: claude

The empty-candidate fallback is unreachable and would incorrectly label an unchecked image as cached if reached.

Evidence: packages/stack/src/runtime/resolve-container-image.ts:37-40 maps any inspect result to cached without reading present. packages/stack/src/model/SlimArtifactMirrors.ts:79-87 always returns at least one candidate.

Suggested fix: Replace the fallback with an explicit invariant failure or make candidate non-emptiness part of the type.

Comment on lines +26 to +31
if (
present(env["CLAUDE_CODE_REMOTE"]) ||
present(env["CLAUDECODE"]) ||
present(env["CLAUDE_CODE"])
)
return "claude";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚪ NIT · dead-code · source: claude

The detected claude host hint never affects candidate ordering and is behaviorally identical to default.

Evidence: packages/stack/src/model/SlimArtifactMirrors.ts:26-31 returns claude, but lines 86 and 129 branch only on codex or cursor. SlimArtifactMirrors.unit.test.ts:43-48 explicitly expects Claude and default ordering to match.

Suggested fix: Remove the unused distinction or document why Claude intentionally retains default ordering instead of claiming every marker reorders candidates.

Comment on lines +70 to +75
const candidates = slimImagePullCandidates(imageName, {
env: mergedEnv(projectEnvValues),
...(overrideValue(override) === undefined
? {}
: { registryOverride: overrideValue(override) }),
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚪ NIT · style · source: claude

Slim registry option construction is duplicated, and overrideValue is evaluated twice in each copy.

Evidence: apps/cli/src/command-internal/docker-registry.ts:67-75 and lines 89-97 contain equivalent slimImagePullCandidates option construction with duplicate overrideValue calls.

Suggested fix: Factor one slim-candidate helper and compute overrideValue once.

Comment on lines +60 to +69
const annotations =
typeof layer["annotations"] === "object" && layer["annotations"] !== null
? (layer["annotations"] as Record<string, string>)
: {};
return [{ mediaType, digest, annotations }];
});
};

const titleOf = (layer: OciLayer): string =>
layer.annotations["org.opencontainers.image.title"] ?? "";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 MINOR · error-handling · source: codex

A malformed OCI title annotation can throw a defect and bypass candidate failover.

Evidence: packages/stack/src/preparation/SlimNativeOci.ts:60-63 casts unvalidated annotation values to strings; titleOf at lines 68-69 can return a number at runtime, and predicates at lines 135-144 call endsWith or includes on it.

Suggested fix: Decode descriptor annotations with a schema or require the title value to be a string before using string methods.

Move payload validation, digest checks, ECR repo creation, and native
copies out of inline workflow Python/bash so the mirror can be tested
and replayed locally.
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.

1 participant