ci(image): publish amd64 and arm64, each built natively - #7

Merged
sajonaro merged 1 commit into
mainfrom
image-multiarch
Aug 24, 2026
Merged

ci(image): publish amd64 and arm64, each built natively#7
sajonaro merged 1 commit into
mainfrom
image-multiarch

Conversation

@sajonaro

Copy link
Copy Markdown
Contributor

Addresses downstream feedback: the published image is amd64-only, so anyone on Apple Silicon pulling ghcr.io/writ-lang/writ runs it under QEMU.

That doesn't fail — it does something quieter. An interrogator that enumerates a state space is exactly the program that turns emulation into a wait, so "published" and "usable by other people" came apart with no error message anywhere.

The approach

A matrix builds each architecture on a runner of that architecture and merges the results into one manifest list — the option the reviewer recommended, over platforms: linux/amd64,linux/arm64 on a single runner.

The one-liner would compile OCaml for arm64 under QEMU on an amd64 host, and run the Dockerfile's stage-2 smoke checks emulated too. That moves the emulation from the user to CI rather than removing it. ubuntu-24.04-arm is free on public repositories, so the honest version costs nothing.

image.yml → two jobs. The matrix pushes each architecture by digest, under no tag; a second job joins the digests with imagetools create and attaches :VERSION and :latest once. Tagging inside the matrix would be two jobs racing for one tag, and the loser's architecture would be the published image — the same bug, harder to see.

image-check.yml → matrixed, for the reason it exists at all: a check that built only amd64 would pass on the PR and leave arm64 to the release. Cache is scoped per architecture, since one shared scope has the two jobs evict each other every run (an arm64 layer is not a usable hit for an amd64 build).

New check

The publish now asserts the tag names both platforms:

for want in linux/amd64 linux/arm64; do
docker buildx imagetools inspect "$image" | grep -q "$want" || exit 1
done

Nothing else can catch a half-published tag: an amd64-only image pulls and runs perfectly on the amd64 runner that would otherwise be "testing" it. That is precisely how this gap survived the first publish.

Verification

Confirmed by querying the registries directly:

factevidence
ocaml/opam:debian-12-ocaml-5.2 has arm64manifest lists linux/arm64 (also amd64, ppc64le)
debian:12-slim has arm64manifest lists linux/arm64 v8
ubuntu-24.04-arm is a real label, free on public reposGitHub Actions runner docs

The arm64 build itself is not verified locally — this machine has no buildx and no binfmt, and docker run --platform linux/arm64 dies with exec format error, so arm64 cannot run here at all. The image-check (arm64) job on this PR is that verification, on real hardware rather than emulation.

Not addressed, deliberately

The reviewer's second paragraph — :latest published alongside 0.1.0, and the client repo pinning the version because make formal's recorded output is a claim about a specific writ — reads as confirmation rather than a request. Both tags are still published; no change.

🤖 Generated with Claude Code

The published image was amd64-only. That does not fail on Apple Silicon;
it does something quieter, which is to pull the amd64 image and run the
whole thing under QEMU. An interrogator that enumerates a state space is
exactly the program that turns emulation into a wait, so "published" and
"usable by other people" came apart with no error message anywhere.
image.yml becomes two jobs. A matrix builds each architecture on a runner
of that architecture and pushes it by digest under no tag; a second job
joins the digests with `imagetools create` and attaches the version and
`latest` once. Tagging inside the matrix would be two jobs racing for one
tag, and the loser's architecture would be the published image — the same
bug, harder to see.
Setting `platforms: linux/amd64,linux/arm64` on one runner is a line
rather than a job, and it compiles OCaml for arm64 under QEMU on an amd64
host: the emulation moved from the user to CI, where the stage-2 smoke
checks run emulated too. arm64 runners are free on public repositories.
The publish gains the check that only a manifest list needs — that the
tag names both architectures. Nothing else can catch a half-published
tag, since an amd64-only image pulls and runs perfectly on the amd64
runner that would be testing it.
image-check.yml matrixes for the reason it exists: a check that built
only amd64 would leave arm64 to be found by the release. Its build cache
is scoped per architecture, because one shared scope has the two jobs
evict each other every run.
Verified: ocaml/opam:debian-12-ocaml-5.2 and debian:12-slim both publish
linux/arm64, and ubuntu-24.04-arm is a real label, free on public repos.
The arm64 BUILD is not verified locally — this machine has no buildx and
no binfmt, so arm64 cannot run here at all. The image-check matrix on
this pull request is that verification, on real hardware.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sajonaro
sajonaro merged commit 084c8d5 into mainAug 24, 2026
3 checks passed
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Aug 24, 2026
@sajonaro
sajonaro deleted the image-multiarch branch August 24, 2026 10:32
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@sajonaro
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

ci(image): publish amd64 and arm64, each built natively - #7

Merged
sajonaro merged 1 commit into
mainfrom
image-multiarch
Aug 24, 2026
Merged

ci(image): publish amd64 and arm64, each built natively#7
sajonaro merged 1 commit into
mainfrom
image-multiarch

Conversation

@sajonaro

Copy link
Copy Markdown
Contributor

Addresses downstream feedback: the published image is amd64-only, so anyone on Apple Silicon pulling ghcr.io/writ-lang/writ runs it under QEMU.

That doesn't fail — it does something quieter. An interrogator that enumerates a state space is exactly the program that turns emulation into a wait, so "published" and "usable by other people" came apart with no error message anywhere.

The approach

A matrix builds each architecture on a runner of that architecture and merges the results into one manifest list — the option the reviewer recommended, over platforms: linux/amd64,linux/arm64 on a single runner.

The one-liner would compile OCaml for arm64 under QEMU on an amd64 host, and run the Dockerfile's stage-2 smoke checks emulated too. That moves the emulation from the user to CI rather than removing it. ubuntu-24.04-arm is free on public repositories, so the honest version costs nothing.

image.yml → two jobs. The matrix pushes each architecture by digest, under no tag; a second job joins the digests with imagetools create and attaches :VERSION and :latest once. Tagging inside the matrix would be two jobs racing for one tag, and the loser's architecture would be the published image — the same bug, harder to see.

image-check.yml → matrixed, for the reason it exists at all: a check that built only amd64 would pass on the PR and leave arm64 to the release. Cache is scoped per architecture, since one shared scope has the two jobs evict each other every run (an arm64 layer is not a usable hit for an amd64 build).

New check

The publish now asserts the tag names both platforms:

for want in linux/amd64 linux/arm64; do
docker buildx imagetools inspect "$image" | grep -q "$want" || exit 1
done

Nothing else can catch a half-published tag: an amd64-only image pulls and runs perfectly on the amd64 runner that would otherwise be "testing" it. That is precisely how this gap survived the first publish.

Verification

Confirmed by querying the registries directly:

factevidence
ocaml/opam:debian-12-ocaml-5.2 has arm64manifest lists linux/arm64 (also amd64, ppc64le)
debian:12-slim has arm64manifest lists linux/arm64 v8
ubuntu-24.04-arm is a real label, free on public reposGitHub Actions runner docs

The arm64 build itself is not verified locally — this machine has no buildx and no binfmt, and docker run --platform linux/arm64 dies with exec format error, so arm64 cannot run here at all. The image-check (arm64) job on this PR is that verification, on real hardware rather than emulation.

Not addressed, deliberately

The reviewer's second paragraph — :latest published alongside 0.1.0, and the client repo pinning the version because make formal's recorded output is a claim about a specific writ — reads as confirmation rather than a request. Both tags are still published; no change.

🤖 Generated with Claude Code

The published image was amd64-only. That does not fail on Apple Silicon;
it does something quieter, which is to pull the amd64 image and run the
whole thing under QEMU. An interrogator that enumerates a state space is
exactly the program that turns emulation into a wait, so "published" and
"usable by other people" came apart with no error message anywhere.
image.yml becomes two jobs. A matrix builds each architecture on a runner
of that architecture and pushes it by digest under no tag; a second job
joins the digests with `imagetools create` and attaches the version and
`latest` once. Tagging inside the matrix would be two jobs racing for one
tag, and the loser's architecture would be the published image — the same
bug, harder to see.
Setting `platforms: linux/amd64,linux/arm64` on one runner is a line
rather than a job, and it compiles OCaml for arm64 under QEMU on an amd64
host: the emulation moved from the user to CI, where the stage-2 smoke
checks run emulated too. arm64 runners are free on public repositories.
The publish gains the check that only a manifest list needs — that the
tag names both architectures. Nothing else can catch a half-published
tag, since an amd64-only image pulls and runs perfectly on the amd64
runner that would be testing it.
image-check.yml matrixes for the reason it exists: a check that built
only amd64 would leave arm64 to be found by the release. Its build cache
is scoped per architecture, because one shared scope has the two jobs
evict each other every run.
Verified: ocaml/opam:debian-12-ocaml-5.2 and debian:12-slim both publish
linux/arm64, and ubuntu-24.04-arm is a real label, free on public repos.
The arm64 BUILD is not verified locally — this machine has no buildx and
no binfmt, so arm64 cannot run here at all. The image-check matrix on
this pull request is that verification, on real hardware.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sajonaro
sajonaro merged commit 084c8d5 into mainAug 24, 2026
3 checks passed
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Aug 24, 2026
@sajonaro
sajonaro deleted the image-multiarch branch August 24, 2026 10:32
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@sajonaro
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

ci(image): publish amd64 and arm64, each built natively - #7

Merged
sajonaro merged 1 commit into
mainfrom
image-multiarch
Aug 24, 2026
Merged

ci(image): publish amd64 and arm64, each built natively#7
sajonaro merged 1 commit into
mainfrom
image-multiarch

Conversation

@sajonaro

Copy link
Copy Markdown
Contributor

Addresses downstream feedback: the published image is amd64-only, so anyone on Apple Silicon pulling ghcr.io/writ-lang/writ runs it under QEMU.

That doesn't fail — it does something quieter. An interrogator that enumerates a state space is exactly the program that turns emulation into a wait, so "published" and "usable by other people" came apart with no error message anywhere.

The approach

A matrix builds each architecture on a runner of that architecture and merges the results into one manifest list — the option the reviewer recommended, over platforms: linux/amd64,linux/arm64 on a single runner.

The one-liner would compile OCaml for arm64 under QEMU on an amd64 host, and run the Dockerfile's stage-2 smoke checks emulated too. That moves the emulation from the user to CI rather than removing it. ubuntu-24.04-arm is free on public repositories, so the honest version costs nothing.

image.yml → two jobs. The matrix pushes each architecture by digest, under no tag; a second job joins the digests with imagetools create and attaches :VERSION and :latest once. Tagging inside the matrix would be two jobs racing for one tag, and the loser's architecture would be the published image — the same bug, harder to see.

image-check.yml → matrixed, for the reason it exists at all: a check that built only amd64 would pass on the PR and leave arm64 to the release. Cache is scoped per architecture, since one shared scope has the two jobs evict each other every run (an arm64 layer is not a usable hit for an amd64 build).

New check

The publish now asserts the tag names both platforms:

for want in linux/amd64 linux/arm64; do
docker buildx imagetools inspect "$image" | grep -q "$want" || exit 1
done

Nothing else can catch a half-published tag: an amd64-only image pulls and runs perfectly on the amd64 runner that would otherwise be "testing" it. That is precisely how this gap survived the first publish.

Verification

Confirmed by querying the registries directly:

factevidence
ocaml/opam:debian-12-ocaml-5.2 has arm64manifest lists linux/arm64 (also amd64, ppc64le)
debian:12-slim has arm64manifest lists linux/arm64 v8
ubuntu-24.04-arm is a real label, free on public reposGitHub Actions runner docs

The arm64 build itself is not verified locally — this machine has no buildx and no binfmt, and docker run --platform linux/arm64 dies with exec format error, so arm64 cannot run here at all. The image-check (arm64) job on this PR is that verification, on real hardware rather than emulation.

Not addressed, deliberately

The reviewer's second paragraph — :latest published alongside 0.1.0, and the client repo pinning the version because make formal's recorded output is a claim about a specific writ — reads as confirmation rather than a request. Both tags are still published; no change.

🤖 Generated with Claude Code

The published image was amd64-only. That does not fail on Apple Silicon;
it does something quieter, which is to pull the amd64 image and run the
whole thing under QEMU. An interrogator that enumerates a state space is
exactly the program that turns emulation into a wait, so "published" and
"usable by other people" came apart with no error message anywhere.
image.yml becomes two jobs. A matrix builds each architecture on a runner
of that architecture and pushes it by digest under no tag; a second job
joins the digests with `imagetools create` and attaches the version and
`latest` once. Tagging inside the matrix would be two jobs racing for one
tag, and the loser's architecture would be the published image — the same
bug, harder to see.
Setting `platforms: linux/amd64,linux/arm64` on one runner is a line
rather than a job, and it compiles OCaml for arm64 under QEMU on an amd64
host: the emulation moved from the user to CI, where the stage-2 smoke
checks run emulated too. arm64 runners are free on public repositories.
The publish gains the check that only a manifest list needs — that the
tag names both architectures. Nothing else can catch a half-published
tag, since an amd64-only image pulls and runs perfectly on the amd64
runner that would be testing it.
image-check.yml matrixes for the reason it exists: a check that built
only amd64 would leave arm64 to be found by the release. Its build cache
is scoped per architecture, because one shared scope has the two jobs
evict each other every run.
Verified: ocaml/opam:debian-12-ocaml-5.2 and debian:12-slim both publish
linux/arm64, and ubuntu-24.04-arm is a real label, free on public repos.
The arm64 BUILD is not verified locally — this machine has no buildx and
no binfmt, so arm64 cannot run here at all. The image-check matrix on
this pull request is that verification, on real hardware.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sajonaro
sajonaro merged commit 084c8d5 into mainAug 24, 2026
3 checks passed
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Aug 24, 2026
@sajonaro
sajonaro deleted the image-multiarch branch August 24, 2026 10:32
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@sajonaro
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

ci(image): publish amd64 and arm64, each built natively - #7

Merged
sajonaro merged 1 commit into
mainfrom
image-multiarch
Aug 24, 2026
Merged

ci(image): publish amd64 and arm64, each built natively#7
sajonaro merged 1 commit into
mainfrom
image-multiarch

Conversation

@sajonaro

Copy link
Copy Markdown
Contributor

Addresses downstream feedback: the published image is amd64-only, so anyone on Apple Silicon pulling ghcr.io/writ-lang/writ runs it under QEMU.

That doesn't fail — it does something quieter. An interrogator that enumerates a state space is exactly the program that turns emulation into a wait, so "published" and "usable by other people" came apart with no error message anywhere.

The approach

A matrix builds each architecture on a runner of that architecture and merges the results into one manifest list — the option the reviewer recommended, over platforms: linux/amd64,linux/arm64 on a single runner.

The one-liner would compile OCaml for arm64 under QEMU on an amd64 host, and run the Dockerfile's stage-2 smoke checks emulated too. That moves the emulation from the user to CI rather than removing it. ubuntu-24.04-arm is free on public repositories, so the honest version costs nothing.

image.yml → two jobs. The matrix pushes each architecture by digest, under no tag; a second job joins the digests with imagetools create and attaches :VERSION and :latest once. Tagging inside the matrix would be two jobs racing for one tag, and the loser's architecture would be the published image — the same bug, harder to see.

image-check.yml → matrixed, for the reason it exists at all: a check that built only amd64 would pass on the PR and leave arm64 to the release. Cache is scoped per architecture, since one shared scope has the two jobs evict each other every run (an arm64 layer is not a usable hit for an amd64 build).

New check

The publish now asserts the tag names both platforms:

for want in linux/amd64 linux/arm64; do
docker buildx imagetools inspect "$image" | grep -q "$want" || exit 1
done

Nothing else can catch a half-published tag: an amd64-only image pulls and runs perfectly on the amd64 runner that would otherwise be "testing" it. That is precisely how this gap survived the first publish.

Verification

Confirmed by querying the registries directly:

factevidence
ocaml/opam:debian-12-ocaml-5.2 has arm64manifest lists linux/arm64 (also amd64, ppc64le)
debian:12-slim has arm64manifest lists linux/arm64 v8
ubuntu-24.04-arm is a real label, free on public reposGitHub Actions runner docs

The arm64 build itself is not verified locally — this machine has no buildx and no binfmt, and docker run --platform linux/arm64 dies with exec format error, so arm64 cannot run here at all. The image-check (arm64) job on this PR is that verification, on real hardware rather than emulation.

Not addressed, deliberately

The reviewer's second paragraph — :latest published alongside 0.1.0, and the client repo pinning the version because make formal's recorded output is a claim about a specific writ — reads as confirmation rather than a request. Both tags are still published; no change.

🤖 Generated with Claude Code

The published image was amd64-only. That does not fail on Apple Silicon;
it does something quieter, which is to pull the amd64 image and run the
whole thing under QEMU. An interrogator that enumerates a state space is
exactly the program that turns emulation into a wait, so "published" and
"usable by other people" came apart with no error message anywhere.
image.yml becomes two jobs. A matrix builds each architecture on a runner
of that architecture and pushes it by digest under no tag; a second job
joins the digests with `imagetools create` and attaches the version and
`latest` once. Tagging inside the matrix would be two jobs racing for one
tag, and the loser's architecture would be the published image — the same
bug, harder to see.
Setting `platforms: linux/amd64,linux/arm64` on one runner is a line
rather than a job, and it compiles OCaml for arm64 under QEMU on an amd64
host: the emulation moved from the user to CI, where the stage-2 smoke
checks run emulated too. arm64 runners are free on public repositories.
The publish gains the check that only a manifest list needs — that the
tag names both architectures. Nothing else can catch a half-published
tag, since an amd64-only image pulls and runs perfectly on the amd64
runner that would be testing it.
image-check.yml matrixes for the reason it exists: a check that built
only amd64 would leave arm64 to be found by the release. Its build cache
is scoped per architecture, because one shared scope has the two jobs
evict each other every run.
Verified: ocaml/opam:debian-12-ocaml-5.2 and debian:12-slim both publish
linux/arm64, and ubuntu-24.04-arm is a real label, free on public repos.
The arm64 BUILD is not verified locally — this machine has no buildx and
no binfmt, so arm64 cannot run here at all. The image-check matrix on
this pull request is that verification, on real hardware.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sajonaro
sajonaro merged commit 084c8d5 into mainAug 24, 2026
3 checks passed
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Aug 24, 2026
@sajonaro
sajonaro deleted the image-multiarch branch August 24, 2026 10:32
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@sajonaro
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

ci(image): publish amd64 and arm64, each built natively - #7

Merged
sajonaro merged 1 commit into
mainfrom
image-multiarch
Aug 24, 2026
Merged

ci(image): publish amd64 and arm64, each built natively#7
sajonaro merged 1 commit into
mainfrom
image-multiarch

Conversation

@sajonaro

Copy link
Copy Markdown
Contributor

Addresses downstream feedback: the published image is amd64-only, so anyone on Apple Silicon pulling ghcr.io/writ-lang/writ runs it under QEMU.

That doesn't fail — it does something quieter. An interrogator that enumerates a state space is exactly the program that turns emulation into a wait, so "published" and "usable by other people" came apart with no error message anywhere.

The approach

A matrix builds each architecture on a runner of that architecture and merges the results into one manifest list — the option the reviewer recommended, over platforms: linux/amd64,linux/arm64 on a single runner.

The one-liner would compile OCaml for arm64 under QEMU on an amd64 host, and run the Dockerfile's stage-2 smoke checks emulated too. That moves the emulation from the user to CI rather than removing it. ubuntu-24.04-arm is free on public repositories, so the honest version costs nothing.

image.yml → two jobs. The matrix pushes each architecture by digest, under no tag; a second job joins the digests with imagetools create and attaches :VERSION and :latest once. Tagging inside the matrix would be two jobs racing for one tag, and the loser's architecture would be the published image — the same bug, harder to see.

image-check.yml → matrixed, for the reason it exists at all: a check that built only amd64 would pass on the PR and leave arm64 to the release. Cache is scoped per architecture, since one shared scope has the two jobs evict each other every run (an arm64 layer is not a usable hit for an amd64 build).

New check

The publish now asserts the tag names both platforms:

for want in linux/amd64 linux/arm64; do
docker buildx imagetools inspect "$image" | grep -q "$want" || exit 1
done

Nothing else can catch a half-published tag: an amd64-only image pulls and runs perfectly on the amd64 runner that would otherwise be "testing" it. That is precisely how this gap survived the first publish.

Verification

Confirmed by querying the registries directly:

factevidence
ocaml/opam:debian-12-ocaml-5.2 has arm64manifest lists linux/arm64 (also amd64, ppc64le)
debian:12-slim has arm64manifest lists linux/arm64 v8
ubuntu-24.04-arm is a real label, free on public reposGitHub Actions runner docs

The arm64 build itself is not verified locally — this machine has no buildx and no binfmt, and docker run --platform linux/arm64 dies with exec format error, so arm64 cannot run here at all. The image-check (arm64) job on this PR is that verification, on real hardware rather than emulation.

Not addressed, deliberately

The reviewer's second paragraph — :latest published alongside 0.1.0, and the client repo pinning the version because make formal's recorded output is a claim about a specific writ — reads as confirmation rather than a request. Both tags are still published; no change.

🤖 Generated with Claude Code

The published image was amd64-only. That does not fail on Apple Silicon;
it does something quieter, which is to pull the amd64 image and run the
whole thing under QEMU. An interrogator that enumerates a state space is
exactly the program that turns emulation into a wait, so "published" and
"usable by other people" came apart with no error message anywhere.
image.yml becomes two jobs. A matrix builds each architecture on a runner
of that architecture and pushes it by digest under no tag; a second job
joins the digests with `imagetools create` and attaches the version and
`latest` once. Tagging inside the matrix would be two jobs racing for one
tag, and the loser's architecture would be the published image — the same
bug, harder to see.
Setting `platforms: linux/amd64,linux/arm64` on one runner is a line
rather than a job, and it compiles OCaml for arm64 under QEMU on an amd64
host: the emulation moved from the user to CI, where the stage-2 smoke
checks run emulated too. arm64 runners are free on public repositories.
The publish gains the check that only a manifest list needs — that the
tag names both architectures. Nothing else can catch a half-published
tag, since an amd64-only image pulls and runs perfectly on the amd64
runner that would be testing it.
image-check.yml matrixes for the reason it exists: a check that built
only amd64 would leave arm64 to be found by the release. Its build cache
is scoped per architecture, because one shared scope has the two jobs
evict each other every run.
Verified: ocaml/opam:debian-12-ocaml-5.2 and debian:12-slim both publish
linux/arm64, and ubuntu-24.04-arm is a real label, free on public repos.
The arm64 BUILD is not verified locally — this machine has no buildx and
no binfmt, so arm64 cannot run here at all. The image-check matrix on
this pull request is that verification, on real hardware.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sajonaro
sajonaro merged commit 084c8d5 into mainAug 24, 2026
3 checks passed
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Aug 24, 2026
@sajonaro
sajonaro deleted the image-multiarch branch August 24, 2026 10:32
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@sajonaro
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

ci(image): publish amd64 and arm64, each built natively - #7

Merged
sajonaro merged 1 commit into
mainfrom
image-multiarch
Aug 24, 2026
Merged

ci(image): publish amd64 and arm64, each built natively#7
sajonaro merged 1 commit into
mainfrom
image-multiarch

Conversation

@sajonaro

Copy link
Copy Markdown
Contributor

Addresses downstream feedback: the published image is amd64-only, so anyone on Apple Silicon pulling ghcr.io/writ-lang/writ runs it under QEMU.

That doesn't fail — it does something quieter. An interrogator that enumerates a state space is exactly the program that turns emulation into a wait, so "published" and "usable by other people" came apart with no error message anywhere.

The approach

A matrix builds each architecture on a runner of that architecture and merges the results into one manifest list — the option the reviewer recommended, over platforms: linux/amd64,linux/arm64 on a single runner.

The one-liner would compile OCaml for arm64 under QEMU on an amd64 host, and run the Dockerfile's stage-2 smoke checks emulated too. That moves the emulation from the user to CI rather than removing it. ubuntu-24.04-arm is free on public repositories, so the honest version costs nothing.

image.yml → two jobs. The matrix pushes each architecture by digest, under no tag; a second job joins the digests with imagetools create and attaches :VERSION and :latest once. Tagging inside the matrix would be two jobs racing for one tag, and the loser's architecture would be the published image — the same bug, harder to see.

image-check.yml → matrixed, for the reason it exists at all: a check that built only amd64 would pass on the PR and leave arm64 to the release. Cache is scoped per architecture, since one shared scope has the two jobs evict each other every run (an arm64 layer is not a usable hit for an amd64 build).

New check

The publish now asserts the tag names both platforms:

for want in linux/amd64 linux/arm64; do
docker buildx imagetools inspect "$image" | grep -q "$want" || exit 1
done

Nothing else can catch a half-published tag: an amd64-only image pulls and runs perfectly on the amd64 runner that would otherwise be "testing" it. That is precisely how this gap survived the first publish.

Verification

Confirmed by querying the registries directly:

factevidence
ocaml/opam:debian-12-ocaml-5.2 has arm64manifest lists linux/arm64 (also amd64, ppc64le)
debian:12-slim has arm64manifest lists linux/arm64 v8
ubuntu-24.04-arm is a real label, free on public reposGitHub Actions runner docs

The arm64 build itself is not verified locally — this machine has no buildx and no binfmt, and docker run --platform linux/arm64 dies with exec format error, so arm64 cannot run here at all. The image-check (arm64) job on this PR is that verification, on real hardware rather than emulation.

Not addressed, deliberately

The reviewer's second paragraph — :latest published alongside 0.1.0, and the client repo pinning the version because make formal's recorded output is a claim about a specific writ — reads as confirmation rather than a request. Both tags are still published; no change.

🤖 Generated with Claude Code

The published image was amd64-only. That does not fail on Apple Silicon;
it does something quieter, which is to pull the amd64 image and run the
whole thing under QEMU. An interrogator that enumerates a state space is
exactly the program that turns emulation into a wait, so "published" and
"usable by other people" came apart with no error message anywhere.
image.yml becomes two jobs. A matrix builds each architecture on a runner
of that architecture and pushes it by digest under no tag; a second job
joins the digests with `imagetools create` and attaches the version and
`latest` once. Tagging inside the matrix would be two jobs racing for one
tag, and the loser's architecture would be the published image — the same
bug, harder to see.
Setting `platforms: linux/amd64,linux/arm64` on one runner is a line
rather than a job, and it compiles OCaml for arm64 under QEMU on an amd64
host: the emulation moved from the user to CI, where the stage-2 smoke
checks run emulated too. arm64 runners are free on public repositories.
The publish gains the check that only a manifest list needs — that the
tag names both architectures. Nothing else can catch a half-published
tag, since an amd64-only image pulls and runs perfectly on the amd64
runner that would be testing it.
image-check.yml matrixes for the reason it exists: a check that built
only amd64 would leave arm64 to be found by the release. Its build cache
is scoped per architecture, because one shared scope has the two jobs
evict each other every run.
Verified: ocaml/opam:debian-12-ocaml-5.2 and debian:12-slim both publish
linux/arm64, and ubuntu-24.04-arm is a real label, free on public repos.
The arm64 BUILD is not verified locally — this machine has no buildx and
no binfmt, so arm64 cannot run here at all. The image-check matrix on
this pull request is that verification, on real hardware.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sajonaro
sajonaro merged commit 084c8d5 into mainAug 24, 2026
3 checks passed
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Aug 24, 2026
@sajonaro
sajonaro deleted the image-multiarch branch August 24, 2026 10:32
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@sajonaro
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

ci(image): publish amd64 and arm64, each built natively - #7

Merged
sajonaro merged 1 commit into
mainfrom
image-multiarch
Aug 24, 2026
Merged

ci(image): publish amd64 and arm64, each built natively#7
sajonaro merged 1 commit into
mainfrom
image-multiarch

Conversation

@sajonaro

Copy link
Copy Markdown
Contributor

Addresses downstream feedback: the published image is amd64-only, so anyone on Apple Silicon pulling ghcr.io/writ-lang/writ runs it under QEMU.

That doesn't fail — it does something quieter. An interrogator that enumerates a state space is exactly the program that turns emulation into a wait, so "published" and "usable by other people" came apart with no error message anywhere.

The approach

A matrix builds each architecture on a runner of that architecture and merges the results into one manifest list — the option the reviewer recommended, over platforms: linux/amd64,linux/arm64 on a single runner.

The one-liner would compile OCaml for arm64 under QEMU on an amd64 host, and run the Dockerfile's stage-2 smoke checks emulated too. That moves the emulation from the user to CI rather than removing it. ubuntu-24.04-arm is free on public repositories, so the honest version costs nothing.

image.yml → two jobs. The matrix pushes each architecture by digest, under no tag; a second job joins the digests with imagetools create and attaches :VERSION and :latest once. Tagging inside the matrix would be two jobs racing for one tag, and the loser's architecture would be the published image — the same bug, harder to see.

image-check.yml → matrixed, for the reason it exists at all: a check that built only amd64 would pass on the PR and leave arm64 to the release. Cache is scoped per architecture, since one shared scope has the two jobs evict each other every run (an arm64 layer is not a usable hit for an amd64 build).

New check

The publish now asserts the tag names both platforms:

for want in linux/amd64 linux/arm64; do
docker buildx imagetools inspect "$image" | grep -q "$want" || exit 1
done

Nothing else can catch a half-published tag: an amd64-only image pulls and runs perfectly on the amd64 runner that would otherwise be "testing" it. That is precisely how this gap survived the first publish.

Verification

Confirmed by querying the registries directly:

factevidence
ocaml/opam:debian-12-ocaml-5.2 has arm64manifest lists linux/arm64 (also amd64, ppc64le)
debian:12-slim has arm64manifest lists linux/arm64 v8
ubuntu-24.04-arm is a real label, free on public reposGitHub Actions runner docs

The arm64 build itself is not verified locally — this machine has no buildx and no binfmt, and docker run --platform linux/arm64 dies with exec format error, so arm64 cannot run here at all. The image-check (arm64) job on this PR is that verification, on real hardware rather than emulation.

Not addressed, deliberately

The reviewer's second paragraph — :latest published alongside 0.1.0, and the client repo pinning the version because make formal's recorded output is a claim about a specific writ — reads as confirmation rather than a request. Both tags are still published; no change.

🤖 Generated with Claude Code

The published image was amd64-only. That does not fail on Apple Silicon;
it does something quieter, which is to pull the amd64 image and run the
whole thing under QEMU. An interrogator that enumerates a state space is
exactly the program that turns emulation into a wait, so "published" and
"usable by other people" came apart with no error message anywhere.
image.yml becomes two jobs. A matrix builds each architecture on a runner
of that architecture and pushes it by digest under no tag; a second job
joins the digests with `imagetools create` and attaches the version and
`latest` once. Tagging inside the matrix would be two jobs racing for one
tag, and the loser's architecture would be the published image — the same
bug, harder to see.
Setting `platforms: linux/amd64,linux/arm64` on one runner is a line
rather than a job, and it compiles OCaml for arm64 under QEMU on an amd64
host: the emulation moved from the user to CI, where the stage-2 smoke
checks run emulated too. arm64 runners are free on public repositories.
The publish gains the check that only a manifest list needs — that the
tag names both architectures. Nothing else can catch a half-published
tag, since an amd64-only image pulls and runs perfectly on the amd64
runner that would be testing it.
image-check.yml matrixes for the reason it exists: a check that built
only amd64 would leave arm64 to be found by the release. Its build cache
is scoped per architecture, because one shared scope has the two jobs
evict each other every run.
Verified: ocaml/opam:debian-12-ocaml-5.2 and debian:12-slim both publish
linux/arm64, and ubuntu-24.04-arm is a real label, free on public repos.
The arm64 BUILD is not verified locally — this machine has no buildx and
no binfmt, so arm64 cannot run here at all. The image-check matrix on
this pull request is that verification, on real hardware.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sajonaro
sajonaro merged commit 084c8d5 into mainAug 24, 2026
3 checks passed
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Aug 24, 2026
@sajonaro
sajonaro deleted the image-multiarch branch August 24, 2026 10:32
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@sajonaro
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

ci(image): publish amd64 and arm64, each built natively - #7

Merged
sajonaro merged 1 commit into
mainfrom
image-multiarch
Aug 24, 2026
Merged

ci(image): publish amd64 and arm64, each built natively#7
sajonaro merged 1 commit into
mainfrom
image-multiarch

Conversation

@sajonaro

Copy link
Copy Markdown
Contributor

Addresses downstream feedback: the published image is amd64-only, so anyone on Apple Silicon pulling ghcr.io/writ-lang/writ runs it under QEMU.

That doesn't fail — it does something quieter. An interrogator that enumerates a state space is exactly the program that turns emulation into a wait, so "published" and "usable by other people" came apart with no error message anywhere.

The approach

A matrix builds each architecture on a runner of that architecture and merges the results into one manifest list — the option the reviewer recommended, over platforms: linux/amd64,linux/arm64 on a single runner.

The one-liner would compile OCaml for arm64 under QEMU on an amd64 host, and run the Dockerfile's stage-2 smoke checks emulated too. That moves the emulation from the user to CI rather than removing it. ubuntu-24.04-arm is free on public repositories, so the honest version costs nothing.

image.yml → two jobs. The matrix pushes each architecture by digest, under no tag; a second job joins the digests with imagetools create and attaches :VERSION and :latest once. Tagging inside the matrix would be two jobs racing for one tag, and the loser's architecture would be the published image — the same bug, harder to see.

image-check.yml → matrixed, for the reason it exists at all: a check that built only amd64 would pass on the PR and leave arm64 to the release. Cache is scoped per architecture, since one shared scope has the two jobs evict each other every run (an arm64 layer is not a usable hit for an amd64 build).

New check

The publish now asserts the tag names both platforms:

for want in linux/amd64 linux/arm64; do
docker buildx imagetools inspect "$image" | grep -q "$want" || exit 1
done

Nothing else can catch a half-published tag: an amd64-only image pulls and runs perfectly on the amd64 runner that would otherwise be "testing" it. That is precisely how this gap survived the first publish.

Verification

Confirmed by querying the registries directly:

factevidence
ocaml/opam:debian-12-ocaml-5.2 has arm64manifest lists linux/arm64 (also amd64, ppc64le)
debian:12-slim has arm64manifest lists linux/arm64 v8
ubuntu-24.04-arm is a real label, free on public reposGitHub Actions runner docs

The arm64 build itself is not verified locally — this machine has no buildx and no binfmt, and docker run --platform linux/arm64 dies with exec format error, so arm64 cannot run here at all. The image-check (arm64) job on this PR is that verification, on real hardware rather than emulation.

Not addressed, deliberately

The reviewer's second paragraph — :latest published alongside 0.1.0, and the client repo pinning the version because make formal's recorded output is a claim about a specific writ — reads as confirmation rather than a request. Both tags are still published; no change.

🤖 Generated with Claude Code

The published image was amd64-only. That does not fail on Apple Silicon;
it does something quieter, which is to pull the amd64 image and run the
whole thing under QEMU. An interrogator that enumerates a state space is
exactly the program that turns emulation into a wait, so "published" and
"usable by other people" came apart with no error message anywhere.
image.yml becomes two jobs. A matrix builds each architecture on a runner
of that architecture and pushes it by digest under no tag; a second job
joins the digests with `imagetools create` and attaches the version and
`latest` once. Tagging inside the matrix would be two jobs racing for one
tag, and the loser's architecture would be the published image — the same
bug, harder to see.
Setting `platforms: linux/amd64,linux/arm64` on one runner is a line
rather than a job, and it compiles OCaml for arm64 under QEMU on an amd64
host: the emulation moved from the user to CI, where the stage-2 smoke
checks run emulated too. arm64 runners are free on public repositories.
The publish gains the check that only a manifest list needs — that the
tag names both architectures. Nothing else can catch a half-published
tag, since an amd64-only image pulls and runs perfectly on the amd64
runner that would be testing it.
image-check.yml matrixes for the reason it exists: a check that built
only amd64 would leave arm64 to be found by the release. Its build cache
is scoped per architecture, because one shared scope has the two jobs
evict each other every run.
Verified: ocaml/opam:debian-12-ocaml-5.2 and debian:12-slim both publish
linux/arm64, and ubuntu-24.04-arm is a real label, free on public repos.
The arm64 BUILD is not verified locally — this machine has no buildx and
no binfmt, so arm64 cannot run here at all. The image-check matrix on
this pull request is that verification, on real hardware.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sajonaro
sajonaro merged commit 084c8d5 into mainAug 24, 2026
3 checks passed
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Aug 24, 2026
@sajonaro
sajonaro deleted the image-multiarch branch August 24, 2026 10:32
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@sajonaro