ci: share one build between the image check and the publish - #8

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

ci: share one build between the image check and the publish#8
sajonaro merged 1 commit into
mainfrom
image-workflow-consolidate

Conversation

@sajonaro

Copy link
Copy Markdown
Contributor

Answers "why are there three workflows?" — by making the answer defensible.

The problem

image.yml and image-check.yml both built this image, and each held its own byte-identical copy of the architecture matrix:

- platform: linux/amd64runner: ubuntu-latestarch: amd64
- platform: linux/arm64runner: ubuntu-24.04-armarch: arm64

That is the same shape as the COPY list in the Dockerfile that shipped a broken image in #5: a hand-kept list, in two places, that nothing forces to agree. Add an architecture to one and forget the other, and the check stops shadowing the publish — which is the one property that makes it a check.

The change

image-build.yml holds the matrix and the buildx invocation, and has no trigger of its own — workflow_call only, so it cannot run by itself. Its single input is push, which is the entire difference between the callers:

pushoutputeffect
falsetype=cacheonlyevery RUN executes, including the stage-2 smoke checks; nothing is exported
truetype=image,push-by-digest=trueeach architecture pushed under no tag, digest recorded for the merge job

image.ymlimage-publish.yml.image and image-check side by side read as "the image one" and "some extra check", which hid that the real difference is publish versus verify.

The permission boundary got stronger, not weaker

I originally justified two files on the token — a PR-triggered job must never hold packages: write — and that was weaker than it sounded, since job-level permissions: can express the same thing in one file. Two things now make it structural:

  • A called workflow is capped by its caller's grant. image-check.yml calls with contents: read, so the shared build cannot push from a pull request no matter what that file asks for.
  • packages: write moved off the top of the publish workflow onto the two jobs that need it, so a job added later doesn't silently inherit the ability to publish.

Verification

checkresult
matrix defined in exactly one fileimage-build.yml:1, others 0
packages: write appears only in image-publish.yml✅ (hits in image-check.yml are comments)
both callers reference the reusable workflow
stale workflows/image.yml referencesone, in the Dockerfile header — updated
type=cacheonly is a real exporterconfirmed via Docker's exporters overview and docker/buildx#2583; the CLI reference page just doesn't enumerate it

The image-check run on this PR is the real test — it exercises the reusable workflow on both architectures, which is the changed machinery.

Note

Manual dispatch is now gh workflow run image-publish.yml --ref main. The old name will not resolve. Renaming also starts a fresh run history for the workflow; the old image entry keeps its past runs.

🤖 Generated with Claude Code

Two workflows built this image and held byte-identical copies of the
architecture matrix to do it. That is the same shape as the COPY list in
the Dockerfile that shipped a broken image: a hand-kept list, in two
places, that nothing forces to agree. Add an architecture to one and
forget the other and the check stops shadowing the publish, which is the
one property that makes it a check at all.
image-build.yml holds the matrix and the buildx invocation now, and has
no trigger of its own — `workflow_call` only. Its single input is `push`,
which is the entire difference between the two callers: false builds to
`type=cacheonly`, running every RUN in the Dockerfile including the
stage-2 smoke checks and exporting nothing; true pushes each architecture
by digest and records it for the merge job.
The permission boundary survives the sharing, and is stronger than
before. A called workflow is capped by its caller's grant, so
image-check.yml calling with `contents: read` cannot push whatever the
shared file asks for. `packages: write` moves off the top of the publish
workflow and onto the two jobs that need it, so a job added later does
not inherit the ability to publish by default.
image.yml becomes image-publish.yml. `image` and `image-check` side by
side read as "the image one" and "some extra check", which hid that the
real difference is publish versus verify. The Dockerfile's header
referenced the old path and now references the new one.
Dispatching by hand is `gh workflow run image-publish.yml --ref main`;
the old name will not resolve.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sajonaro
sajonaro merged commit b054efa into mainAug 24, 2026
3 checks passed
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Aug 24, 2026
@sajonaro
sajonaro deleted the image-workflow-consolidate branch August 24, 2026 10:52
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: share one build between the image check and the publish - #8

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

ci: share one build between the image check and the publish#8
sajonaro merged 1 commit into
mainfrom
image-workflow-consolidate

Conversation

@sajonaro

Copy link
Copy Markdown
Contributor

Answers "why are there three workflows?" — by making the answer defensible.

The problem

image.yml and image-check.yml both built this image, and each held its own byte-identical copy of the architecture matrix:

- platform: linux/amd64runner: ubuntu-latestarch: amd64
- platform: linux/arm64runner: ubuntu-24.04-armarch: arm64

That is the same shape as the COPY list in the Dockerfile that shipped a broken image in #5: a hand-kept list, in two places, that nothing forces to agree. Add an architecture to one and forget the other, and the check stops shadowing the publish — which is the one property that makes it a check.

The change

image-build.yml holds the matrix and the buildx invocation, and has no trigger of its own — workflow_call only, so it cannot run by itself. Its single input is push, which is the entire difference between the callers:

pushoutputeffect
falsetype=cacheonlyevery RUN executes, including the stage-2 smoke checks; nothing is exported
truetype=image,push-by-digest=trueeach architecture pushed under no tag, digest recorded for the merge job

image.ymlimage-publish.yml.image and image-check side by side read as "the image one" and "some extra check", which hid that the real difference is publish versus verify.

The permission boundary got stronger, not weaker

I originally justified two files on the token — a PR-triggered job must never hold packages: write — and that was weaker than it sounded, since job-level permissions: can express the same thing in one file. Two things now make it structural:

  • A called workflow is capped by its caller's grant. image-check.yml calls with contents: read, so the shared build cannot push from a pull request no matter what that file asks for.
  • packages: write moved off the top of the publish workflow onto the two jobs that need it, so a job added later doesn't silently inherit the ability to publish.

Verification

checkresult
matrix defined in exactly one fileimage-build.yml:1, others 0
packages: write appears only in image-publish.yml✅ (hits in image-check.yml are comments)
both callers reference the reusable workflow
stale workflows/image.yml referencesone, in the Dockerfile header — updated
type=cacheonly is a real exporterconfirmed via Docker's exporters overview and docker/buildx#2583; the CLI reference page just doesn't enumerate it

The image-check run on this PR is the real test — it exercises the reusable workflow on both architectures, which is the changed machinery.

Note

Manual dispatch is now gh workflow run image-publish.yml --ref main. The old name will not resolve. Renaming also starts a fresh run history for the workflow; the old image entry keeps its past runs.

🤖 Generated with Claude Code

Two workflows built this image and held byte-identical copies of the
architecture matrix to do it. That is the same shape as the COPY list in
the Dockerfile that shipped a broken image: a hand-kept list, in two
places, that nothing forces to agree. Add an architecture to one and
forget the other and the check stops shadowing the publish, which is the
one property that makes it a check at all.
image-build.yml holds the matrix and the buildx invocation now, and has
no trigger of its own — `workflow_call` only. Its single input is `push`,
which is the entire difference between the two callers: false builds to
`type=cacheonly`, running every RUN in the Dockerfile including the
stage-2 smoke checks and exporting nothing; true pushes each architecture
by digest and records it for the merge job.
The permission boundary survives the sharing, and is stronger than
before. A called workflow is capped by its caller's grant, so
image-check.yml calling with `contents: read` cannot push whatever the
shared file asks for. `packages: write` moves off the top of the publish
workflow and onto the two jobs that need it, so a job added later does
not inherit the ability to publish by default.
image.yml becomes image-publish.yml. `image` and `image-check` side by
side read as "the image one" and "some extra check", which hid that the
real difference is publish versus verify. The Dockerfile's header
referenced the old path and now references the new one.
Dispatching by hand is `gh workflow run image-publish.yml --ref main`;
the old name will not resolve.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sajonaro
sajonaro merged commit b054efa into mainAug 24, 2026
3 checks passed
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Aug 24, 2026
@sajonaro
sajonaro deleted the image-workflow-consolidate branch August 24, 2026 10:52
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: share one build between the image check and the publish - #8

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

ci: share one build between the image check and the publish#8
sajonaro merged 1 commit into
mainfrom
image-workflow-consolidate

Conversation

@sajonaro

Copy link
Copy Markdown
Contributor

Answers "why are there three workflows?" — by making the answer defensible.

The problem

image.yml and image-check.yml both built this image, and each held its own byte-identical copy of the architecture matrix:

- platform: linux/amd64runner: ubuntu-latestarch: amd64
- platform: linux/arm64runner: ubuntu-24.04-armarch: arm64

That is the same shape as the COPY list in the Dockerfile that shipped a broken image in #5: a hand-kept list, in two places, that nothing forces to agree. Add an architecture to one and forget the other, and the check stops shadowing the publish — which is the one property that makes it a check.

The change

image-build.yml holds the matrix and the buildx invocation, and has no trigger of its own — workflow_call only, so it cannot run by itself. Its single input is push, which is the entire difference between the callers:

pushoutputeffect
falsetype=cacheonlyevery RUN executes, including the stage-2 smoke checks; nothing is exported
truetype=image,push-by-digest=trueeach architecture pushed under no tag, digest recorded for the merge job

image.ymlimage-publish.yml.image and image-check side by side read as "the image one" and "some extra check", which hid that the real difference is publish versus verify.

The permission boundary got stronger, not weaker

I originally justified two files on the token — a PR-triggered job must never hold packages: write — and that was weaker than it sounded, since job-level permissions: can express the same thing in one file. Two things now make it structural:

  • A called workflow is capped by its caller's grant. image-check.yml calls with contents: read, so the shared build cannot push from a pull request no matter what that file asks for.
  • packages: write moved off the top of the publish workflow onto the two jobs that need it, so a job added later doesn't silently inherit the ability to publish.

Verification

checkresult
matrix defined in exactly one fileimage-build.yml:1, others 0
packages: write appears only in image-publish.yml✅ (hits in image-check.yml are comments)
both callers reference the reusable workflow
stale workflows/image.yml referencesone, in the Dockerfile header — updated
type=cacheonly is a real exporterconfirmed via Docker's exporters overview and docker/buildx#2583; the CLI reference page just doesn't enumerate it

The image-check run on this PR is the real test — it exercises the reusable workflow on both architectures, which is the changed machinery.

Note

Manual dispatch is now gh workflow run image-publish.yml --ref main. The old name will not resolve. Renaming also starts a fresh run history for the workflow; the old image entry keeps its past runs.

🤖 Generated with Claude Code

Two workflows built this image and held byte-identical copies of the
architecture matrix to do it. That is the same shape as the COPY list in
the Dockerfile that shipped a broken image: a hand-kept list, in two
places, that nothing forces to agree. Add an architecture to one and
forget the other and the check stops shadowing the publish, which is the
one property that makes it a check at all.
image-build.yml holds the matrix and the buildx invocation now, and has
no trigger of its own — `workflow_call` only. Its single input is `push`,
which is the entire difference between the two callers: false builds to
`type=cacheonly`, running every RUN in the Dockerfile including the
stage-2 smoke checks and exporting nothing; true pushes each architecture
by digest and records it for the merge job.
The permission boundary survives the sharing, and is stronger than
before. A called workflow is capped by its caller's grant, so
image-check.yml calling with `contents: read` cannot push whatever the
shared file asks for. `packages: write` moves off the top of the publish
workflow and onto the two jobs that need it, so a job added later does
not inherit the ability to publish by default.
image.yml becomes image-publish.yml. `image` and `image-check` side by
side read as "the image one" and "some extra check", which hid that the
real difference is publish versus verify. The Dockerfile's header
referenced the old path and now references the new one.
Dispatching by hand is `gh workflow run image-publish.yml --ref main`;
the old name will not resolve.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sajonaro
sajonaro merged commit b054efa into mainAug 24, 2026
3 checks passed
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Aug 24, 2026
@sajonaro
sajonaro deleted the image-workflow-consolidate branch August 24, 2026 10:52
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: share one build between the image check and the publish - #8

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

ci: share one build between the image check and the publish#8
sajonaro merged 1 commit into
mainfrom
image-workflow-consolidate

Conversation

@sajonaro

Copy link
Copy Markdown
Contributor

Answers "why are there three workflows?" — by making the answer defensible.

The problem

image.yml and image-check.yml both built this image, and each held its own byte-identical copy of the architecture matrix:

- platform: linux/amd64runner: ubuntu-latestarch: amd64
- platform: linux/arm64runner: ubuntu-24.04-armarch: arm64

That is the same shape as the COPY list in the Dockerfile that shipped a broken image in #5: a hand-kept list, in two places, that nothing forces to agree. Add an architecture to one and forget the other, and the check stops shadowing the publish — which is the one property that makes it a check.

The change

image-build.yml holds the matrix and the buildx invocation, and has no trigger of its own — workflow_call only, so it cannot run by itself. Its single input is push, which is the entire difference between the callers:

pushoutputeffect
falsetype=cacheonlyevery RUN executes, including the stage-2 smoke checks; nothing is exported
truetype=image,push-by-digest=trueeach architecture pushed under no tag, digest recorded for the merge job

image.ymlimage-publish.yml.image and image-check side by side read as "the image one" and "some extra check", which hid that the real difference is publish versus verify.

The permission boundary got stronger, not weaker

I originally justified two files on the token — a PR-triggered job must never hold packages: write — and that was weaker than it sounded, since job-level permissions: can express the same thing in one file. Two things now make it structural:

  • A called workflow is capped by its caller's grant. image-check.yml calls with contents: read, so the shared build cannot push from a pull request no matter what that file asks for.
  • packages: write moved off the top of the publish workflow onto the two jobs that need it, so a job added later doesn't silently inherit the ability to publish.

Verification

checkresult
matrix defined in exactly one fileimage-build.yml:1, others 0
packages: write appears only in image-publish.yml✅ (hits in image-check.yml are comments)
both callers reference the reusable workflow
stale workflows/image.yml referencesone, in the Dockerfile header — updated
type=cacheonly is a real exporterconfirmed via Docker's exporters overview and docker/buildx#2583; the CLI reference page just doesn't enumerate it

The image-check run on this PR is the real test — it exercises the reusable workflow on both architectures, which is the changed machinery.

Note

Manual dispatch is now gh workflow run image-publish.yml --ref main. The old name will not resolve. Renaming also starts a fresh run history for the workflow; the old image entry keeps its past runs.

🤖 Generated with Claude Code

Two workflows built this image and held byte-identical copies of the
architecture matrix to do it. That is the same shape as the COPY list in
the Dockerfile that shipped a broken image: a hand-kept list, in two
places, that nothing forces to agree. Add an architecture to one and
forget the other and the check stops shadowing the publish, which is the
one property that makes it a check at all.
image-build.yml holds the matrix and the buildx invocation now, and has
no trigger of its own — `workflow_call` only. Its single input is `push`,
which is the entire difference between the two callers: false builds to
`type=cacheonly`, running every RUN in the Dockerfile including the
stage-2 smoke checks and exporting nothing; true pushes each architecture
by digest and records it for the merge job.
The permission boundary survives the sharing, and is stronger than
before. A called workflow is capped by its caller's grant, so
image-check.yml calling with `contents: read` cannot push whatever the
shared file asks for. `packages: write` moves off the top of the publish
workflow and onto the two jobs that need it, so a job added later does
not inherit the ability to publish by default.
image.yml becomes image-publish.yml. `image` and `image-check` side by
side read as "the image one" and "some extra check", which hid that the
real difference is publish versus verify. The Dockerfile's header
referenced the old path and now references the new one.
Dispatching by hand is `gh workflow run image-publish.yml --ref main`;
the old name will not resolve.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sajonaro
sajonaro merged commit b054efa into mainAug 24, 2026
3 checks passed
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Aug 24, 2026
@sajonaro
sajonaro deleted the image-workflow-consolidate branch August 24, 2026 10:52
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: share one build between the image check and the publish - #8

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

ci: share one build between the image check and the publish#8
sajonaro merged 1 commit into
mainfrom
image-workflow-consolidate

Conversation

@sajonaro

Copy link
Copy Markdown
Contributor

Answers "why are there three workflows?" — by making the answer defensible.

The problem

image.yml and image-check.yml both built this image, and each held its own byte-identical copy of the architecture matrix:

- platform: linux/amd64runner: ubuntu-latestarch: amd64
- platform: linux/arm64runner: ubuntu-24.04-armarch: arm64

That is the same shape as the COPY list in the Dockerfile that shipped a broken image in #5: a hand-kept list, in two places, that nothing forces to agree. Add an architecture to one and forget the other, and the check stops shadowing the publish — which is the one property that makes it a check.

The change

image-build.yml holds the matrix and the buildx invocation, and has no trigger of its own — workflow_call only, so it cannot run by itself. Its single input is push, which is the entire difference between the callers:

pushoutputeffect
falsetype=cacheonlyevery RUN executes, including the stage-2 smoke checks; nothing is exported
truetype=image,push-by-digest=trueeach architecture pushed under no tag, digest recorded for the merge job

image.ymlimage-publish.yml.image and image-check side by side read as "the image one" and "some extra check", which hid that the real difference is publish versus verify.

The permission boundary got stronger, not weaker

I originally justified two files on the token — a PR-triggered job must never hold packages: write — and that was weaker than it sounded, since job-level permissions: can express the same thing in one file. Two things now make it structural:

  • A called workflow is capped by its caller's grant. image-check.yml calls with contents: read, so the shared build cannot push from a pull request no matter what that file asks for.
  • packages: write moved off the top of the publish workflow onto the two jobs that need it, so a job added later doesn't silently inherit the ability to publish.

Verification

checkresult
matrix defined in exactly one fileimage-build.yml:1, others 0
packages: write appears only in image-publish.yml✅ (hits in image-check.yml are comments)
both callers reference the reusable workflow
stale workflows/image.yml referencesone, in the Dockerfile header — updated
type=cacheonly is a real exporterconfirmed via Docker's exporters overview and docker/buildx#2583; the CLI reference page just doesn't enumerate it

The image-check run on this PR is the real test — it exercises the reusable workflow on both architectures, which is the changed machinery.

Note

Manual dispatch is now gh workflow run image-publish.yml --ref main. The old name will not resolve. Renaming also starts a fresh run history for the workflow; the old image entry keeps its past runs.

🤖 Generated with Claude Code

Two workflows built this image and held byte-identical copies of the
architecture matrix to do it. That is the same shape as the COPY list in
the Dockerfile that shipped a broken image: a hand-kept list, in two
places, that nothing forces to agree. Add an architecture to one and
forget the other and the check stops shadowing the publish, which is the
one property that makes it a check at all.
image-build.yml holds the matrix and the buildx invocation now, and has
no trigger of its own — `workflow_call` only. Its single input is `push`,
which is the entire difference between the two callers: false builds to
`type=cacheonly`, running every RUN in the Dockerfile including the
stage-2 smoke checks and exporting nothing; true pushes each architecture
by digest and records it for the merge job.
The permission boundary survives the sharing, and is stronger than
before. A called workflow is capped by its caller's grant, so
image-check.yml calling with `contents: read` cannot push whatever the
shared file asks for. `packages: write` moves off the top of the publish
workflow and onto the two jobs that need it, so a job added later does
not inherit the ability to publish by default.
image.yml becomes image-publish.yml. `image` and `image-check` side by
side read as "the image one" and "some extra check", which hid that the
real difference is publish versus verify. The Dockerfile's header
referenced the old path and now references the new one.
Dispatching by hand is `gh workflow run image-publish.yml --ref main`;
the old name will not resolve.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sajonaro
sajonaro merged commit b054efa into mainAug 24, 2026
3 checks passed
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Aug 24, 2026
@sajonaro
sajonaro deleted the image-workflow-consolidate branch August 24, 2026 10:52
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: share one build between the image check and the publish - #8

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

ci: share one build between the image check and the publish#8
sajonaro merged 1 commit into
mainfrom
image-workflow-consolidate

Conversation

@sajonaro

Copy link
Copy Markdown
Contributor

Answers "why are there three workflows?" — by making the answer defensible.

The problem

image.yml and image-check.yml both built this image, and each held its own byte-identical copy of the architecture matrix:

- platform: linux/amd64runner: ubuntu-latestarch: amd64
- platform: linux/arm64runner: ubuntu-24.04-armarch: arm64

That is the same shape as the COPY list in the Dockerfile that shipped a broken image in #5: a hand-kept list, in two places, that nothing forces to agree. Add an architecture to one and forget the other, and the check stops shadowing the publish — which is the one property that makes it a check.

The change

image-build.yml holds the matrix and the buildx invocation, and has no trigger of its own — workflow_call only, so it cannot run by itself. Its single input is push, which is the entire difference between the callers:

pushoutputeffect
falsetype=cacheonlyevery RUN executes, including the stage-2 smoke checks; nothing is exported
truetype=image,push-by-digest=trueeach architecture pushed under no tag, digest recorded for the merge job

image.ymlimage-publish.yml.image and image-check side by side read as "the image one" and "some extra check", which hid that the real difference is publish versus verify.

The permission boundary got stronger, not weaker

I originally justified two files on the token — a PR-triggered job must never hold packages: write — and that was weaker than it sounded, since job-level permissions: can express the same thing in one file. Two things now make it structural:

  • A called workflow is capped by its caller's grant. image-check.yml calls with contents: read, so the shared build cannot push from a pull request no matter what that file asks for.
  • packages: write moved off the top of the publish workflow onto the two jobs that need it, so a job added later doesn't silently inherit the ability to publish.

Verification

checkresult
matrix defined in exactly one fileimage-build.yml:1, others 0
packages: write appears only in image-publish.yml✅ (hits in image-check.yml are comments)
both callers reference the reusable workflow
stale workflows/image.yml referencesone, in the Dockerfile header — updated
type=cacheonly is a real exporterconfirmed via Docker's exporters overview and docker/buildx#2583; the CLI reference page just doesn't enumerate it

The image-check run on this PR is the real test — it exercises the reusable workflow on both architectures, which is the changed machinery.

Note

Manual dispatch is now gh workflow run image-publish.yml --ref main. The old name will not resolve. Renaming also starts a fresh run history for the workflow; the old image entry keeps its past runs.

🤖 Generated with Claude Code

Two workflows built this image and held byte-identical copies of the
architecture matrix to do it. That is the same shape as the COPY list in
the Dockerfile that shipped a broken image: a hand-kept list, in two
places, that nothing forces to agree. Add an architecture to one and
forget the other and the check stops shadowing the publish, which is the
one property that makes it a check at all.
image-build.yml holds the matrix and the buildx invocation now, and has
no trigger of its own — `workflow_call` only. Its single input is `push`,
which is the entire difference between the two callers: false builds to
`type=cacheonly`, running every RUN in the Dockerfile including the
stage-2 smoke checks and exporting nothing; true pushes each architecture
by digest and records it for the merge job.
The permission boundary survives the sharing, and is stronger than
before. A called workflow is capped by its caller's grant, so
image-check.yml calling with `contents: read` cannot push whatever the
shared file asks for. `packages: write` moves off the top of the publish
workflow and onto the two jobs that need it, so a job added later does
not inherit the ability to publish by default.
image.yml becomes image-publish.yml. `image` and `image-check` side by
side read as "the image one" and "some extra check", which hid that the
real difference is publish versus verify. The Dockerfile's header
referenced the old path and now references the new one.
Dispatching by hand is `gh workflow run image-publish.yml --ref main`;
the old name will not resolve.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sajonaro
sajonaro merged commit b054efa into mainAug 24, 2026
3 checks passed
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Aug 24, 2026
@sajonaro
sajonaro deleted the image-workflow-consolidate branch August 24, 2026 10:52
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: share one build between the image check and the publish - #8

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

ci: share one build between the image check and the publish#8
sajonaro merged 1 commit into
mainfrom
image-workflow-consolidate

Conversation

@sajonaro

Copy link
Copy Markdown
Contributor

Answers "why are there three workflows?" — by making the answer defensible.

The problem

image.yml and image-check.yml both built this image, and each held its own byte-identical copy of the architecture matrix:

- platform: linux/amd64runner: ubuntu-latestarch: amd64
- platform: linux/arm64runner: ubuntu-24.04-armarch: arm64

That is the same shape as the COPY list in the Dockerfile that shipped a broken image in #5: a hand-kept list, in two places, that nothing forces to agree. Add an architecture to one and forget the other, and the check stops shadowing the publish — which is the one property that makes it a check.

The change

image-build.yml holds the matrix and the buildx invocation, and has no trigger of its own — workflow_call only, so it cannot run by itself. Its single input is push, which is the entire difference between the callers:

pushoutputeffect
falsetype=cacheonlyevery RUN executes, including the stage-2 smoke checks; nothing is exported
truetype=image,push-by-digest=trueeach architecture pushed under no tag, digest recorded for the merge job

image.ymlimage-publish.yml.image and image-check side by side read as "the image one" and "some extra check", which hid that the real difference is publish versus verify.

The permission boundary got stronger, not weaker

I originally justified two files on the token — a PR-triggered job must never hold packages: write — and that was weaker than it sounded, since job-level permissions: can express the same thing in one file. Two things now make it structural:

  • A called workflow is capped by its caller's grant. image-check.yml calls with contents: read, so the shared build cannot push from a pull request no matter what that file asks for.
  • packages: write moved off the top of the publish workflow onto the two jobs that need it, so a job added later doesn't silently inherit the ability to publish.

Verification

checkresult
matrix defined in exactly one fileimage-build.yml:1, others 0
packages: write appears only in image-publish.yml✅ (hits in image-check.yml are comments)
both callers reference the reusable workflow
stale workflows/image.yml referencesone, in the Dockerfile header — updated
type=cacheonly is a real exporterconfirmed via Docker's exporters overview and docker/buildx#2583; the CLI reference page just doesn't enumerate it

The image-check run on this PR is the real test — it exercises the reusable workflow on both architectures, which is the changed machinery.

Note

Manual dispatch is now gh workflow run image-publish.yml --ref main. The old name will not resolve. Renaming also starts a fresh run history for the workflow; the old image entry keeps its past runs.

🤖 Generated with Claude Code

Two workflows built this image and held byte-identical copies of the
architecture matrix to do it. That is the same shape as the COPY list in
the Dockerfile that shipped a broken image: a hand-kept list, in two
places, that nothing forces to agree. Add an architecture to one and
forget the other and the check stops shadowing the publish, which is the
one property that makes it a check at all.
image-build.yml holds the matrix and the buildx invocation now, and has
no trigger of its own — `workflow_call` only. Its single input is `push`,
which is the entire difference between the two callers: false builds to
`type=cacheonly`, running every RUN in the Dockerfile including the
stage-2 smoke checks and exporting nothing; true pushes each architecture
by digest and records it for the merge job.
The permission boundary survives the sharing, and is stronger than
before. A called workflow is capped by its caller's grant, so
image-check.yml calling with `contents: read` cannot push whatever the
shared file asks for. `packages: write` moves off the top of the publish
workflow and onto the two jobs that need it, so a job added later does
not inherit the ability to publish by default.
image.yml becomes image-publish.yml. `image` and `image-check` side by
side read as "the image one" and "some extra check", which hid that the
real difference is publish versus verify. The Dockerfile's header
referenced the old path and now references the new one.
Dispatching by hand is `gh workflow run image-publish.yml --ref main`;
the old name will not resolve.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sajonaro
sajonaro merged commit b054efa into mainAug 24, 2026
3 checks passed
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Aug 24, 2026
@sajonaro
sajonaro deleted the image-workflow-consolidate branch August 24, 2026 10:52
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: share one build between the image check and the publish - #8

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

ci: share one build between the image check and the publish#8
sajonaro merged 1 commit into
mainfrom
image-workflow-consolidate

Conversation

@sajonaro

Copy link
Copy Markdown
Contributor

Answers "why are there three workflows?" — by making the answer defensible.

The problem

image.yml and image-check.yml both built this image, and each held its own byte-identical copy of the architecture matrix:

- platform: linux/amd64runner: ubuntu-latestarch: amd64
- platform: linux/arm64runner: ubuntu-24.04-armarch: arm64

That is the same shape as the COPY list in the Dockerfile that shipped a broken image in #5: a hand-kept list, in two places, that nothing forces to agree. Add an architecture to one and forget the other, and the check stops shadowing the publish — which is the one property that makes it a check.

The change

image-build.yml holds the matrix and the buildx invocation, and has no trigger of its own — workflow_call only, so it cannot run by itself. Its single input is push, which is the entire difference between the callers:

pushoutputeffect
falsetype=cacheonlyevery RUN executes, including the stage-2 smoke checks; nothing is exported
truetype=image,push-by-digest=trueeach architecture pushed under no tag, digest recorded for the merge job

image.ymlimage-publish.yml.image and image-check side by side read as "the image one" and "some extra check", which hid that the real difference is publish versus verify.

The permission boundary got stronger, not weaker

I originally justified two files on the token — a PR-triggered job must never hold packages: write — and that was weaker than it sounded, since job-level permissions: can express the same thing in one file. Two things now make it structural:

  • A called workflow is capped by its caller's grant. image-check.yml calls with contents: read, so the shared build cannot push from a pull request no matter what that file asks for.
  • packages: write moved off the top of the publish workflow onto the two jobs that need it, so a job added later doesn't silently inherit the ability to publish.

Verification

checkresult
matrix defined in exactly one fileimage-build.yml:1, others 0
packages: write appears only in image-publish.yml✅ (hits in image-check.yml are comments)
both callers reference the reusable workflow
stale workflows/image.yml referencesone, in the Dockerfile header — updated
type=cacheonly is a real exporterconfirmed via Docker's exporters overview and docker/buildx#2583; the CLI reference page just doesn't enumerate it

The image-check run on this PR is the real test — it exercises the reusable workflow on both architectures, which is the changed machinery.

Note

Manual dispatch is now gh workflow run image-publish.yml --ref main. The old name will not resolve. Renaming also starts a fresh run history for the workflow; the old image entry keeps its past runs.

🤖 Generated with Claude Code

Two workflows built this image and held byte-identical copies of the
architecture matrix to do it. That is the same shape as the COPY list in
the Dockerfile that shipped a broken image: a hand-kept list, in two
places, that nothing forces to agree. Add an architecture to one and
forget the other and the check stops shadowing the publish, which is the
one property that makes it a check at all.
image-build.yml holds the matrix and the buildx invocation now, and has
no trigger of its own — `workflow_call` only. Its single input is `push`,
which is the entire difference between the two callers: false builds to
`type=cacheonly`, running every RUN in the Dockerfile including the
stage-2 smoke checks and exporting nothing; true pushes each architecture
by digest and records it for the merge job.
The permission boundary survives the sharing, and is stronger than
before. A called workflow is capped by its caller's grant, so
image-check.yml calling with `contents: read` cannot push whatever the
shared file asks for. `packages: write` moves off the top of the publish
workflow and onto the two jobs that need it, so a job added later does
not inherit the ability to publish by default.
image.yml becomes image-publish.yml. `image` and `image-check` side by
side read as "the image one" and "some extra check", which hid that the
real difference is publish versus verify. The Dockerfile's header
referenced the old path and now references the new one.
Dispatching by hand is `gh workflow run image-publish.yml --ref main`;
the old name will not resolve.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sajonaro
sajonaro merged commit b054efa into mainAug 24, 2026
3 checks passed
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Aug 24, 2026
@sajonaro
sajonaro deleted the image-workflow-consolidate branch August 24, 2026 10:52
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