From b6b7ba34e298c27e289297cdfb2ac11081f91e4d Mon Sep 17 00:00:00 2001 From: swackhamer Date: Sat, 8 Aug 2026 15:29:39 -0700 Subject: [PATCH 1/2] ci(docker): add a publish-nothing dry run The Docker layer cache and the image build were only ever exercised by a real release, which is the worst place to find out the cache backend is misconfigured -- and the WarpBuild proxy settings this repo now uses (127.0.0.1:49160 plus the network=host buildx driver-opt) had never executed at all. workflow_dispatch now builds both architectures exactly as a release does, and publishes nothing: push=false on the build output, and the Docker Hub login, smoke test, digest export/upload and the whole manifest-list job are skipped. The ancestry gate is short-circuited for dispatch on purpose rather than by accident -- it exists to keep an unreviewed commit off Docker Hub, and a run that cannot push has nothing to gate. Everything above the outputs line is identical between a dry run and a release, so the dry run measures the real build and populates the real cache. Co-Authored-By: Claude Opus 5 --- .github/workflows/docker.yml | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index eb504d8ad..86b87e3ca 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -3,6 +3,13 @@ name: Docker on: push: tags: ['v*'] + # Dry run. The layer cache and the build itself are only otherwise + # exercised by a real release, which is a bad place to discover that the + # cache backend is misconfigured. A dispatch builds both architectures + # exactly as a release does but PUBLISHES NOTHING: no registry push, no + # digest artifacts, no manifest list, no tag moved. See the `dry_run` + # expression on the build step's outputs. + workflow_dispatch: concurrency: # The workflow only accepts tag pushes. Include both the tag and target SHA @@ -35,6 +42,14 @@ jobs: id: ancestry run: | set -euo pipefail + # A dispatch publishes nothing, so there is no tag to vouch for and + # nothing for this gate to protect. Allow it through explicitly + # rather than by accident: the gate exists to stop an unreviewed + # commit reaching Docker Hub, and a dry run cannot. + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "allowed=true" >> "$GITHUB_OUTPUT" + exit 0 + fi git fetch --no-tags origin main:refs/remotes/origin/main if git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then echo "allowed=true" >> "$GITHUB_OUTPUT" @@ -71,6 +86,7 @@ jobs: network=host - name: Log in to Docker Hub + if: github.event_name != 'workflow_dispatch' uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} @@ -97,7 +113,10 @@ jobs: build-args: | VERSION=${{ github.ref_name }} REVISION=${{ github.sha }} - outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true + # push=false on a dispatch. Everything above this line is identical + # between a dry run and a release, so the dry run measures the real + # build and populates the real cache. + outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'workflow_dispatch' }} cache-from: type=gha,url=http://127.0.0.1:49160/,scope=oxidex-${{ matrix.arch }},version=1 cache-to: type=gha,url=http://127.0.0.1:49160/,scope=oxidex-${{ matrix.arch }},mode=max,version=1 @@ -106,6 +125,7 @@ jobs: # job: that runs on ubuntu-latest and could not execute the arm64 image # without the emulation this design avoids. - name: Smoke test + if: github.event_name != 'workflow_dispatch' env: DIGEST: ${{ steps.build.outputs.digest }} run: | @@ -129,6 +149,7 @@ jobs: echo "Smoke test passed for $ref" - name: Export digest + if: github.event_name != 'workflow_dispatch' env: DIGEST: ${{ steps.build.outputs.digest }} run: | @@ -137,6 +158,7 @@ jobs: touch "${RUNNER_TEMP}/digests/${DIGEST#sha256:}" - name: Upload digest + if: github.event_name != 'workflow_dispatch' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: digest-${{ matrix.arch }} @@ -147,7 +169,7 @@ jobs: merge: name: Publish manifest list needs: [verify-tag-on-main, build] - if: needs.verify-tag-on-main.outputs.allowed == 'true' && needs.build.result == 'success' + if: github.event_name != 'workflow_dispatch' && needs.verify-tag-on-main.outputs.allowed == 'true' && needs.build.result == 'success' # 2x: no image build happens here -- this downloads two digest stubs and # calls `buildx imagetools create`, which is registry I/O end to end. It # is on a Warp runner rather than ubuntu-latest to avoid the GitHub-hosted From c855cde28a47930b4f54a200298679b99538a92c Mon Sep 17 00:00:00 2001 From: swackhamer Date: Sat, 8 Aug 2026 15:37:31 -0700 Subject: [PATCH 2/2] fix(jpeg): use libc::c_char so the arm64 build compiles perl_sprintf_g declared its snprintf buffer as [0_i8; 32]. c_char is signed on x86_64 but UNSIGNED on aarch64, so passing *mut i8 where the target expects *mut u8 is a hard type error: this never compiled for aarch64-unknown-linux-musl. error[E0308]: mismatched types --> src/core/jpeg_helpers.rs:1725:33 | expected `*mut u8`, found `*mut i8` Every arm64 Docker image build failed on it, and release.yml builds the same target. It went unnoticed because arm64 is built only by those two workflows, both of which run solely on a version tag -- so the failure would first appear during a release. Found by the Docker dry run added in the previous commit, which is the only way to exercise an arm64 build without cutting a release. The `as u8` on the read side is left as is: it is a no-op where c_char is unsigned and a reinterpret where it is signed, and both are correct for the ASCII that %g emits. Co-Authored-By: Claude Opus 5 --- src/core/jpeg_helpers.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/core/jpeg_helpers.rs b/src/core/jpeg_helpers.rs index 3381f9c04..95609b42e 100644 --- a/src/core/jpeg_helpers.rs +++ b/src/core/jpeg_helpers.rs @@ -1716,7 +1716,12 @@ pub fn process_dji_thermal_segments(segments: &[Segment], metadata: &mut Metadat /// multiplied by positive 100, which is reproduced before calling `snprintf`. fn perl_sprintf_g(value: f64) -> String { let value = if value == 0.0 { 0.0 } else { value }; - let mut output = [0_i8; 32]; + // `libc::c_char`, not a literal `i8`: it is signed on x86_64 and UNSIGNED + // on aarch64, so hardcoding either side compiles on one architecture and + // fails on the other. This was `[0_i8; 32]`, which broke every arm64 + // build -- caught by the Docker dry run, since arm64 is only built there + // and in release.yml. + let mut output = [0 as libc::c_char; 32]; // SAFETY: `output` is writable for its full reported size, the format is a // static NUL-terminated C string with one `%g`, and `value` has the // required promoted `double` type. A default-precision rendering of any @@ -1727,7 +1732,9 @@ fn perl_sprintf_g(value: f64) -> String { let length = usize::try_from(length).unwrap_or(0).min(output.len() - 1); let bytes = &output[..length]; // `%g` emits ASCII digits, punctuation, exponent markers, or the - // implementation's ASCII inf/nan spelling. + // implementation's ASCII inf/nan spelling. `as u8` is a no-op where + // c_char is already unsigned and a reinterpret where it is signed; both + // are correct for ASCII. String::from_utf8(bytes.iter().map(|byte| *byte as u8).collect()).expect("C %g output is ASCII") } /// Emits `APP3:ImagingData`, the InfiRay IR + thermal + visible payload.