diff --git a/.github/workflows/package-image-row.yml b/.github/workflows/package-image-row.yml index 90916ee..2840ee8 100644 --- a/.github/workflows/package-image-row.yml +++ b/.github/workflows/package-image-row.yml @@ -39,6 +39,8 @@ jobs: outputs: package_base_image: ${{ steps.bases.outputs.package_base_image }} runtime_base_image: ${{ steps.bases.outputs.runtime_base_image }} + registry_cache_selected: ${{ steps.bases.outputs.registry_cache_selected }} + depot_registry_host: ${{ steps.bases.outputs.depot_registry_host }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: @@ -54,8 +56,16 @@ jobs: - name: Resolve exact package and runtime base images id: bases env: + CACHE_ENABLED: ${{ vars.DEPOT_REGISTRY_CACHE_ENABLED }} + DEPOT_REGISTRY_HOST: ${{ vars.DEPOT_REGISTRY_HOST }} + EVENT_NAME: ${{ github.event_name }} PACKAGE_BASE: ${{ fromJSON(inputs.row_json).package_base_image }} + PACKAGE_CACHE_REPOSITORY: ${{ fromJSON(inputs.row_json).package_base_cache_repository }} + REPOSITORY: ${{ github.repository }} RUNTIME_BASE: ${{ fromJSON(inputs.row_json).runtime_base_image }} + RUNTIME_CACHE_REPOSITORY: ${{ fromJSON(inputs.row_json).runtime_base_cache_repository }} + WORKFLOW_REF: ${{ github.workflow_ref }} + WORKFLOW_SOURCE_REF: ${{ github.ref }} shell: bash run: | set -euo pipefail @@ -75,10 +85,66 @@ jobs: } package_base_image="$(resolve_ref "$PACKAGE_BASE")" runtime_base_image="$(resolve_ref "$RUNTIME_BASE")" + registry_cache_selected=false + depot_registry_host= + + if [[ "$CACHE_ENABLED" == true && + "$REPOSITORY" == Mesh-LLM/mesh-packaging && + "$WORKFLOW_SOURCE_REF" == refs/heads/main && + "$WORKFLOW_REF" == Mesh-LLM/mesh-packaging/.github/workflows/images-release.yml@refs/heads/main ]]; then + case "$EVENT_NAME" in + repository_dispatch|workflow_dispatch) ;; + *) echo "pull-through bases rejected event: $EVENT_NAME" >&2; exit 1 ;; + esac + [[ "$DEPOT_REGISTRY_HOST" =~ ^[a-z0-9-]+\.registry\.depot\.dev$ ]] || { + echo "DEPOT_REGISTRY_HOST is invalid" >&2 + exit 1 + } + for repository in "$PACKAGE_CACHE_REPOSITORY" "$RUNTIME_CACHE_REPOSITORY"; do + [[ "$repository" =~ ^[a-z0-9]+([._/-]?[a-z0-9]+)*$ && + "$repository" != *..* ]] || { + echo "invalid pull-through repository: $repository" >&2 + exit 1 + } + done + package_base_image="$DEPOT_REGISTRY_HOST/$PACKAGE_CACHE_REPOSITORY@${package_base_image##*@}" + runtime_base_image="$DEPOT_REGISTRY_HOST/$RUNTIME_CACHE_REPOSITORY@${runtime_base_image##*@}" + registry_cache_selected=true + depot_registry_host="$DEPOT_REGISTRY_HOST" + fi { echo "package_base_image=$package_base_image" echo "runtime_base_image=$runtime_base_image" + echo "registry_cache_selected=$registry_cache_selected" + echo "depot_registry_host=$depot_registry_host" } >> "$GITHUB_OUTPUT" + - name: Authenticate pull-through base images + if: steps.bases.outputs.registry_cache_selected == 'true' + env: + DEPOT_REGISTRY_HOST: ${{ steps.bases.outputs.depot_registry_host }} + shell: bash + run: | + set -euo pipefail + depot pull-token --project "$DEPOT_PROJECT_ID" | + docker login "$DEPOT_REGISTRY_HOST" --username x-token --password-stdin + - name: Verify exact pull-through base manifests + if: steps.bases.outputs.registry_cache_selected == 'true' + env: + PACKAGE_BASE_IMAGE: ${{ steps.bases.outputs.package_base_image }} + RUNTIME_BASE_IMAGE: ${{ steps.bases.outputs.runtime_base_image }} + shell: bash + run: | + set -euo pipefail + for image in "$PACKAGE_BASE_IMAGE" "$RUNTIME_BASE_IMAGE"; do + raw="$(mktemp)" + docker buildx imagetools inspect "$image" --raw > "$raw" + actual_digest="sha256:$(sha256sum "$raw" | awk '{ print $1 }')" + rm -f "$raw" + [[ "$actual_digest" == "${image##*@}" ]] || { + echo "pull-through digest mismatch for $image" >&2 + exit 1 + } + done - name: Build native package from verified upstream product bundle id: package uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1 @@ -205,6 +271,15 @@ jobs: uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1 with: version: 2.101.77 + - name: Authenticate pull-through runtime base + if: needs.package.outputs.registry_cache_selected == 'true' + env: + DEPOT_REGISTRY_HOST: ${{ needs.package.outputs.depot_registry_host }} + shell: bash + run: | + set -euo pipefail + depot pull-token --project "$DEPOT_PROJECT_ID" | + docker login "$DEPOT_REGISTRY_HOST" --username x-token --password-stdin - name: Read immutable product inputs id: product shell: bash @@ -302,6 +377,15 @@ jobs: uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1 with: version: 2.101.77 + - name: Authenticate pull-through runtime base + if: needs.package.outputs.registry_cache_selected == 'true' + env: + DEPOT_REGISTRY_HOST: ${{ needs.package.outputs.depot_registry_host }} + shell: bash + run: | + set -euo pipefail + depot pull-token --project "$DEPOT_PROJECT_ID" | + docker login "$DEPOT_REGISTRY_HOST" --username x-token --password-stdin - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4 with: registry: ghcr.io diff --git a/README.md b/README.md index b9b27c9..e0e5c23 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,13 @@ that exact local image without registry writes. Publishing runs push a run-scoped staging image, test it by digest, assemble a canonical release index, and promote the tested digest without rebuilding. +Trusted release runs may opt into Depot Registry pull-through mirrors for the +Ubuntu, CUDA, ROCm, and Arch base repositories. The mirror keeps the resolved +upstream digest and uses a short-lived Depot pull token; dry runs and other +contexts retain the public upstream reference. See +[`docs/publishing.md`](docs/publishing.md#depot-registry-pull-through-cache) for +the measurement gate, dashboard mapping, variables, and rollback procedure. + The upstream release repository must send this payload after its GitHub Release is published: ```json diff --git a/TODO.md b/TODO.md index 81c170e..51e9e0f 100644 --- a/TODO.md +++ b/TODO.md @@ -1,5 +1,15 @@ # Production Readiness TODO +- [x] Add measured Depot Registry pull-through base-image support. + Final result: trusted image rows may substitute configured Depot mirrors for + Ubuntu, CUDA, ROCm, and Arch bases while retaining the original tag or digest; + dry runs and untrusted contexts keep public upstream references, and OIDC + creates only short-lived read-only pull credentials. + QA: the matrix validation, 100%-coverage matrix suite, full 90-test TypeScript + suite, representative Ubuntu/CUDA/ROCm/Arch expansions, actionlint, + shellcheck, workflow policy scans, and `git diff --check` pass. The optional + local Dockerfile check could not connect to Docker (`failed to build: EOF`). + - [x] Persist package-manager downloads independently of Docker layers. Final result: the native package stage uses locked, stable BuildKit cache IDs separated by distro and architecture. The runtime stage additionally diff --git a/docs/matrix.md b/docs/matrix.md index de4c654..f17a7e1 100644 --- a/docs/matrix.md +++ b/docs/matrix.md @@ -7,6 +7,12 @@ backend-neutral host for its specific OS/architecture plus exactly one runtime. Each active row declares its distro, backend display version, `upstream_flavor`, package format/base, runtime base, platforms, support level, and release track. Matrix expansion derives the upstream archive/checksum URLs, deduplicated archive artifact ID, package artifact name, GitHub-hosted runner, and OCI tags. +`depot_registry.repositories` also maps each canonical public base repository +to its independently configured Depot pull-through repository. Validation +requires mappings for both base images of every declared row, including rows +that are currently disabled, so a newly enabled target cannot silently select +an undefined mirror. + ## Active rows | Variant | Platforms | Upstream archive flavor | Package | Track | diff --git a/docs/publishing.md b/docs/publishing.md index e1ec6c4..87e6eed 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -75,3 +75,37 @@ production payload sets `dry_run=false`, `publish_images=true`, and `publish_release_assets=true`. Upstream's client Docker workflow remains available only as manual, non-publishing validation. npm publication is independently controlled by `publish_npm`. + +## Depot Registry pull-through cache + +Pull-through caching is an optional base-image optimization, not a release +requirement. Before enabling it, use the `mesh-llm` Depot Registry canary to +compare at least five cold and five warm pulls of the same digest. Adopt a +mapping only when the warm median saves both at least 20 percent and 10 seconds, +with every sample resolving to the upstream digest. + +Create one Depot Registry pull-through repository for each upstream path. For +the Docker Hub upstream `https://registry-1.docker.io`, the checked-in mapping +in `packaging/images.json` expects: + +- `library/ubuntu` -> `dockerhub-ubuntu` +- `nvidia/cuda` -> `dockerhub-nvidia-cuda` +- `rocm/dev-ubuntu-24.04` -> `dockerhub-rocm-dev-ubuntu-24-04` +- `library/archlinux` -> `dockerhub-archlinux` +- `library/alpine` -> `dockerhub-alpine` (reserved for the disabled Alpine row) + +After every enabled mapping meets the threshold, set +`DEPOT_REGISTRY_HOST` to the organization host ending in +`.registry.depot.dev`, then set `DEPOT_REGISTRY_CACHE_ENABLED=true`. The reusable +row workflow selects mirrors only for exact `main` executions of the canonical +release caller and obtains a short-lived read-only pull token through GitHub +OIDC. No long-lived registry secret is required. It resolves public references +first, retains their exact digest in the Depot reference, and verifies the +mirrored manifest before building. + +This cache can reduce cold base pulls and public-registry rate-limit delays. It +does not accelerate apt, Cargo, pnpm/npm, native compilation, or Docker layer +export. Keep the existing BuildKit and package-manager caches as the primary +optimizations. To roll back immediately, set +`DEPOT_REGISTRY_CACHE_ENABLED=false`; the workflow returns to the original +public references without changing the matrix. diff --git a/packaging/images.json b/packaging/images.json index 7f0e390..022909e 100644 --- a/packaging/images.json +++ b/packaging/images.json @@ -4,6 +4,15 @@ "default_name": "ghcr.io/mesh-llm/mesh-llm", "source_repository": "Mesh-LLM/mesh-llm" }, + "depot_registry": { + "repositories": { + "docker.io/library/alpine": "dockerhub-alpine", + "docker.io/library/archlinux": "dockerhub-archlinux", + "docker.io/library/ubuntu": "dockerhub-ubuntu", + "docker.io/nvidia/cuda": "dockerhub-nvidia-cuda", + "docker.io/rocm/dev-ubuntu-24.04": "dockerhub-rocm-dev-ubuntu-24-04" + } + }, "homebrew": { "arch": "arm64", "runner": "macos-15", diff --git a/scripts/image-matrix.ts b/scripts/image-matrix.ts index 4a4b641..ed5aba2 100644 --- a/scripts/image-matrix.ts +++ b/scripts/image-matrix.ts @@ -12,6 +12,10 @@ type ImageConfig = { source_repository?: string; }; +type DepotRegistryConfig = { + repositories?: Record; +}; + type HomebrewConfig = { arch?: string; runner?: string; @@ -57,6 +61,7 @@ export type Variant = { export type Config = { schema_version?: number; image?: ImageConfig; + depot_registry?: DepotRegistryConfig; homebrew?: HomebrewConfig; npm?: NpmConfig; platform_arches?: Record; @@ -81,9 +86,11 @@ export type MatrixRow = { backend_version: string; upstream_flavor: UpstreamFlavor; package_base_image: string; + package_base_cache_repository: string; package_manager: string; package_format: string; runtime_base_image: string; + runtime_base_cache_repository: string; mesh_ref: string; mesh_repository: string; mesh_version: string; @@ -147,6 +154,8 @@ const DISTRO_PACKAGE_FORMATS: Record = { arch: "pkg.tar.zst", }; +const DEPOT_REPOSITORY_PATTERN = /^[a-z0-9]+(?:[._/-]?[a-z0-9]+)*$/; + export function loadConfig(path: string): Config { return JSON.parse(readFileSync(path, "utf8")) as Config; } @@ -166,6 +175,28 @@ export function backendSuffix(backend: string, backendVersion = ""): string { return backendVersion ? `${backend}${backendVersion}` : backend; } +export function baseRepository(reference: string): string { + let name = reference.trim().split("@", 1)[0]; + const lastSlash = name.lastIndexOf("/"); + const lastColon = name.lastIndexOf(":"); + if (lastColon > lastSlash) name = name.slice(0, lastColon); + if (!name || /\s/.test(name)) throw new Error(`invalid base image: ${reference}`); + const components = name.split("/"); + if (components.length === 1) return `docker.io/library/${name}`; + const registry = components[0]; + if (registry.includes(".") || registry.includes(":") || registry === "localhost") { + return name; + } + return `docker.io/${name}`; +} + +function depotRepositoryFor(config: Config, reference: string): string { + const upstream = baseRepository(reference); + const repository = config.depot_registry?.repositories?.[upstream]; + if (!repository) throw new Error(`missing Depot pull-through repository for ${upstream}`); + return repository; +} + export function targetTriple(platform: string): string { if (platform === "linux/amd64") return "x86_64-unknown-linux-gnu"; if (platform === "linux/arm64") return "aarch64-unknown-linux-gnu"; @@ -232,6 +263,19 @@ export function validate(config: Config): string[] { if (config.schema_version !== 2) errors.push("schema_version must be 2"); if (!config.image?.default_name) errors.push("image.default_name is required"); if (!config.image?.source_repository) errors.push("image.source_repository is required"); + const depotRepositories = config.depot_registry?.repositories; + if (!depotRepositories || Object.keys(depotRepositories).length === 0) { + errors.push("depot_registry.repositories must be a non-empty object"); + } else { + for (const [upstream, repository] of Object.entries(depotRepositories)) { + if (baseRepository(upstream) !== upstream) { + errors.push(`depot_registry upstream must be canonical: ${upstream}`); + } + if (!DEPOT_REPOSITORY_PATTERN.test(repository) || repository.includes("..")) { + errors.push(`invalid Depot repository name for ${upstream}`); + } + } + } if (!config.homebrew?.arch || !config.homebrew.runner || !config.homebrew.target || config.homebrew.upstream_flavor !== "metal") { errors.push("homebrew must define arch, runner, target, and upstream_flavor=metal"); } @@ -266,6 +310,13 @@ export function validate(config: Config): string[] { for (const key of ["package_base_image", "runtime_base_image"] as const) { if (!variant[key]) errors.push(`${prefix}.${key} is required`); + else { + try { + depotRepositoryFor(config, variant[key]); + } catch (error) { + errors.push(`${prefix}.${key}: ${(error as Error).message}`); + } + } } const packageFormat = variant.package_format ?? ""; if (!SUPPORTED_PACKAGE_FORMATS.includes(packageFormat)) { @@ -430,9 +481,11 @@ export function matrixRows( backend_version: variant.backend_version ?? "", upstream_flavor: flavor, package_base_image: requiredString(variant.package_base_image), + package_base_cache_repository: depotRepositoryFor(config, requiredString(variant.package_base_image)), package_manager: variant.package_manager ?? PACKAGE_MANAGERS_BY_FORMAT[requiredString(variant.package_format)] ?? "", package_format: requiredString(variant.package_format), runtime_base_image: requiredString(variant.runtime_base_image), + runtime_base_cache_repository: depotRepositoryFor(config, requiredString(variant.runtime_base_image)), mesh_ref: meshRef, mesh_repository: meshRepository, mesh_version: version, diff --git a/tests/image-matrix.test.ts b/tests/image-matrix.test.ts index 6c77359..7a47339 100644 --- a/tests/image-matrix.test.ts +++ b/tests/image-matrix.test.ts @@ -6,7 +6,7 @@ import { dirname, resolve } from "node:path"; import { test } from "node:test"; import { fileURLToPath } from "node:url"; import { - backendSuffix, homebrewPlan, loadConfig, main, matrixRows, normalizeVersion, parseFilter, + backendSuffix, baseRepository, homebrewPlan, loadConfig, main, matrixRows, normalizeVersion, parseFilter, npmMatrixRows, npmPlan, runnerLabels, stableStringify, targetTriple, upstreamAssetName, upstreamRows, validate, } from "../scripts/image-matrix.ts"; @@ -42,6 +42,8 @@ test("repository config models the supported upstream archive and packaging cont const cpu = rows.find((row) => row.artifact_id === "ubuntu-cpu-amd64")!; assert.equal(cpu.upstream_asset_name, "mesh-llm-v0.73.1-x86_64-unknown-linux-gnu.tar.gz"); assert.equal(cpu.package_file, "mesh-llm-0.73.1-ubuntu-amd64-cpu.deb"); + assert.equal(cpu.package_base_cache_repository, "dockerhub-ubuntu"); + assert.equal(cpu.runtime_base_cache_repository, "dockerhub-ubuntu"); assert.equal(cpu.tags, `${IMAGE}:0.73.1-ubuntu-amd64-cpu\n${IMAGE}:ubuntu-amd64-cpu`); const armCuda = rows.find((row) => row.artifact_id === "ubuntu-cuda-13.1.2-arm64")!; assert.equal(armCuda.runner_labels, '"ubuntu-24.04-arm"'); @@ -50,6 +52,12 @@ test("repository config models the supported upstream archive and packaging cont assert.equal(arch.package_file, "mesh-llm-0.73.1-arch-amd64-cuda13.3.1.pkg.tar.zst"); assert.equal(arch.release_track, "downstream_extension"); assert.equal(arch.package_manager, "pacman"); + assert.equal(arch.package_base_cache_repository, "dockerhub-archlinux"); + assert.equal(arch.runtime_base_cache_repository, "dockerhub-archlinux"); + const cuda = rows.find((row) => row.artifact_id === "ubuntu-cuda-12.9.2-amd64")!; + assert.equal(cuda.runtime_base_cache_repository, "dockerhub-nvidia-cuda"); + const rocm = rows.find((row) => row.artifact_id === "ubuntu-rocm-7.0-amd64")!; + assert.equal(rocm.runtime_base_cache_repository, "dockerhub-rocm-dev-ubuntu-24-04"); }); test("filters and disabled rows are deterministic", () => { @@ -112,6 +120,7 @@ test("matrix defaults remain deterministic for partially specified validated fie assert.equal(rows[0].package_manager, "apt"); const partial = { + depot_registry: { repositories: { "docker.io/library/x": "dockerhub-x" } }, variants: [{ id: "partial", upstream_flavor: "cpu", runtime_base_image: "x", package_base_image: "x", platforms: ["linux/amd64"] }], }; rows = matrixRows(partial, IMAGE, "0.73.1", "v0.73.1", "Mesh-LLM/mesh-llm", new Set(), new Set(), false); @@ -127,6 +136,10 @@ test("naming helpers preserve the upstream release ABI", () => { assert.equal(backendSuffix("cpu"), "cpu"); assert.equal(backendSuffix("vulkan"), "vulkan"); assert.equal(backendSuffix("cuda", "13.1"), "cuda13.1"); + assert.equal(baseRepository("ubuntu:24.04"), "docker.io/library/ubuntu"); + assert.equal(baseRepository("nvidia/cuda:13.1.2-runtime-ubuntu24.04"), "docker.io/nvidia/cuda"); + assert.equal(baseRepository("ghcr.io/actions/actions-runner@sha256:abc"), "ghcr.io/actions/actions-runner"); + assert.throws(() => baseRepository("bad image"), /invalid base image/); assert.equal(targetTriple("linux/amd64"), "x86_64-unknown-linux-gnu"); assert.equal(targetTriple("linux/arm64"), "aarch64-unknown-linux-gnu"); assert.throws(() => targetTriple("darwin/amd64"), /unsupported/); @@ -148,6 +161,7 @@ test("validation reports every contract category", () => { const value = config(); value.schema_version = 1; value.image = {}; + value.depot_registry = { repositories: { "ubuntu:24.04": "BAD..REPOSITORY" } }; value.homebrew = {}; value.variants = [ { id: "duplicate", distro: "nope", backend: "cuda", backend_version: "", upstream_flavor: "cpu", package_format: "rpm", package_manager: "bad", platforms: [] }, @@ -159,7 +173,7 @@ test("validation reports every contract category", () => { { id: "bad-flavor", distro: "ubuntu", backend: "cpu", upstream_flavor: "invalid", package_base_image: "x", runtime_base_image: "y", package_format: "deb", platforms: ["linux/amd64"] }, ]; const errors = validate(value).join("\n"); - for (const message of ["schema_version", "image.default_name", "homebrew", "duplicate variant", "backend_version", "upstream_flavor", "package_base_image", "package_format", "package_manager", "release_track", "Alpine", "unknown platform", "unsupported Arch", "id is required"]) assert.match(errors, new RegExp(message)); + for (const message of ["schema_version", "image.default_name", "depot_registry upstream", "invalid Depot repository", "missing Depot pull-through repository", "homebrew", "duplicate variant", "backend_version", "upstream_flavor", "package_base_image", "package_format", "package_manager", "release_track", "Alpine", "unknown platform", "unsupported Arch", "id is required"]) assert.match(errors, new RegExp(message)); const badNpm = config(); badNpm.npm = { package_name: "bad", diff --git a/tests/release-workflow.test.ts b/tests/release-workflow.test.ts index 3cb194d..34b7849 100644 --- a/tests/release-workflow.test.ts +++ b/tests/release-workflow.test.ts @@ -73,6 +73,31 @@ test("package and image BuildKit work uses the Depot project cache", () => { assert.doesNotMatch(row, /cache-(?:from|to): type=gha/); }); +test("pull-through bases are opt-in, trusted, digest-pinned, and short-lived", () => { + const packageJob = section(row, " package:", " dry-image:"); + const dry = section(row, " dry-image:", " stage-image:"); + const stage = section(row, " stage-image:"); + + assert.match(packageJob, /CACHE_ENABLED: \$\{\{ vars\.DEPOT_REGISTRY_CACHE_ENABLED \}\}/); + assert.match(packageJob, /DEPOT_REGISTRY_HOST: \$\{\{ vars\.DEPOT_REGISTRY_HOST \}\}/); + assert.match(packageJob, /"\$REPOSITORY" == Mesh-LLM\/mesh-packaging/); + assert.match(packageJob, /"\$WORKFLOW_SOURCE_REF" == refs\/heads\/main/); + assert.match(packageJob, /images-release\.yml@refs\/heads\/main/); + assert.match(packageJob, /repository_dispatch\|workflow_dispatch/); + assert.match(packageJob, /package_base_image="\$DEPOT_REGISTRY_HOST\/\$PACKAGE_CACHE_REPOSITORY@\$\{package_base_image##\*@\}"/); + assert.match(packageJob, /runtime_base_image="\$DEPOT_REGISTRY_HOST\/\$RUNTIME_CACHE_REPOSITORY@\$\{runtime_base_image##\*@\}"/); + assert.match(packageJob, /Verify exact pull-through base manifests/); + + for (const job of [packageJob, dry, stage]) { + assert.match(job, /depot pull-token --project "\$DEPOT_PROJECT_ID"/); + assert.match(job, /docker login "\$DEPOT_REGISTRY_HOST" --username x-token --password-stdin/); + } + assert.equal(row.split("depot pull-token --project").length - 1, 3); + assert.doesNotMatch(row, /DEPOT_REGISTRY_PULL_TOKEN|secrets\.DEPOT|DEPOT_TOKEN/); + assert.match(dry, /RUNTIME_BASE_IMAGE=\$\{\{ needs\.package\.outputs\.runtime_base_image \}\}/); + assert.match(stage, /RUNTIME_BASE_IMAGE=\$\{\{ needs\.package\.outputs\.runtime_base_image \}\}/); +}); + test("new reusable and image-index actions use immutable commits", () => { const externalActionReferences = [...row.matchAll( /^\s*(?:-\s+)?uses:\s*([^@\s]+)@([^\s#]+)/gm,