diff --git a/.github/workflows/pr-docker-smoke.yml b/.github/workflows/pr-docker-smoke.yml index ead65c9..2468d53 100644 --- a/.github/workflows/pr-docker-smoke.yml +++ b/.github/workflows/pr-docker-smoke.yml @@ -61,32 +61,4 @@ jobs: --tag ivi-cli-mock:pr-smoke . - name: Smoke test (HEALTHCHECK + SCPI roundtrip) - run: | - set -euo pipefail - docker run -d --name mock-smoke ivi-cli-mock:pr-smoke - for i in $(seq 1 30); do - status="$(docker inspect --format '{{.State.Health.Status}}' mock-smoke)" - if [ "$status" = "healthy" ]; then - echo "container healthy after ${i}s" - break - fi - sleep 1 - done - if [ "$(docker inspect --format '{{.State.Health.Status}}' mock-smoke)" != "healthy" ]; then - echo "container failed to become healthy" - docker logs mock-smoke - exit 1 - fi - response="$(docker exec mock-smoke bash -c 'echo "*IDN?" | nc -w 2 127.0.0.1 5025')" - echo "got: $response" - case "$response" in - *IVICLI-MOCK*) - echo "smoke passed" - ;; - *) - echo "smoke FAILED — unexpected response" - docker logs mock-smoke - exit 1 - ;; - esac - docker rm -f mock-smoke + run: sh docker/smoke-test.sh ivi-cli-mock:pr-smoke diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ba04be1..58c5366 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,17 +45,20 @@ jobs: timeout-minutes: 30 strategy: fail-fast: false + # Each RID builds, tests, and smoke-runs on a runner of the SAME + # OS/architecture, so every shipped binary has been executed at + # least once before release (ADR 0016). matrix: include: - os: ubuntu-latest rid: linux-x64 - - os: ubuntu-latest + - os: ubuntu-24.04-arm rid: linux-arm64 - os: windows-latest rid: win-x64 - - os: windows-latest + - os: windows-11-arm rid: win-arm64 - - os: macos-latest + - os: macos-15-intel rid: osx-x64 - os: macos-latest rid: osx-arm64 @@ -110,6 +113,28 @@ jobs: -p:Version=${{ steps.version.outputs.value }} \ -o ./artifacts/${{ matrix.rid }}-fxdep + # Execute the exact artifact that ships: the runner arch matches + # the RID, so a publish that produces a non-runnable binary + # (bad RID mapping, trimming fault, missing native lib) fails + # here instead of on a user's machine. + - name: Smoke-run published binary + shell: bash + run: | + set -euo pipefail + bin="./artifacts/${{ matrix.rid }}-selfcontained/ivicli" + [ -f "$bin" ] || bin="${bin}.exe" + out="$("$bin" --version)" + echo "ivicli --version -> $out" + case "$out" in + "${{ steps.version.outputs.value }}"*) + echo "smoke passed" + ;; + *) + echo "smoke FAILED — expected version ${{ steps.version.outputs.value }}" + exit 1 + ;; + esac + - name: Upload binaries uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: @@ -175,11 +200,39 @@ jobs: name: ivicli-nupkg path: ./artifacts/nupkg + # Native-arm64 counterpart of the amd64 smoke gate inside the + # `docker` job below: builds the linux/arm64 single-arch image on an + # arm64 runner and runs the same HEALTHCHECK + SCPI round-trip, so + # the arm64 half of the multi-arch manifest is executed before the + # push (ADR 0016). No QEMU — emulated .NET is slow and flaky. + docker-smoke-arm64: + name: docker / mock-VISA smoke (arm64) + runs-on: ubuntu-24.04-arm + timeout-minutes: 20 + needs: publish + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Download linux-arm64 self-contained artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ivicli-linux-arm64 + path: ./artifacts + + - name: Build single-arch (arm64) for smoke + run: | + docker build -f docker/Dockerfile \ + --build-arg RID=linux-arm64 \ + --tag ivi-cli-mock:smoke-arm64 . + + - name: Run smoke test + run: sh docker/smoke-test.sh ivi-cli-mock:smoke-arm64 + docker: name: docker / mock-VISA container runs-on: ubuntu-latest timeout-minutes: 20 - needs: publish + needs: [publish, docker-smoke-arm64] steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -239,38 +292,7 @@ jobs: --tag ivi-cli-mock:smoke . - name: Run smoke test - run: | - set -euo pipefail - docker run -d --name mock-smoke -p 4880:4880 -p 5025:5025 ivi-cli-mock:smoke - # Wait until the HEALTHCHECK reports healthy (max 30 s). - for i in $(seq 1 30); do - status="$(docker inspect --format '{{.State.Health.Status}}' mock-smoke)" - if [ "$status" = "healthy" ]; then - echo "container healthy after ${i}s" - break - fi - sleep 1 - done - if [ "$(docker inspect --format '{{.State.Health.Status}}' mock-smoke)" != "healthy" ]; then - echo "container failed to become healthy" - docker logs mock-smoke - exit 1 - fi - # SCPI round-trip over the SOCKET gateway (no client framing - # required). Expect the baked default IDN response. - response="$(docker exec mock-smoke bash -c 'echo "*IDN?" | nc -w 2 127.0.0.1 5025')" - echo "got: $response" - case "$response" in - *IVICLI-MOCK*) - echo "smoke passed" - ;; - *) - echo "smoke FAILED — unexpected response" - docker logs mock-smoke - exit 1 - ;; - esac - docker rm -f mock-smoke + run: sh docker/smoke-test.sh ivi-cli-mock:smoke - name: Build + push multi-arch manifest uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 diff --git a/docker/smoke-test.sh b/docker/smoke-test.sh new file mode 100644 index 0000000..3634fc5 --- /dev/null +++ b/docker/smoke-test.sh @@ -0,0 +1,44 @@ +#!/bin/sh +# HEALTHCHECK + SCPI round-trip smoke against a locally built +# ivi-cli-mock image. Shared by pr-docker-smoke.yml and both +# release.yml container smoke gates (amd64 + arm64). +# +# Usage: smoke-test.sh +set -eu + +image="${1:?usage: smoke-test.sh }" +name="mock-smoke" + +docker rm -f "$name" >/dev/null 2>&1 || true +docker run -d --name "$name" "$image" + +for i in $(seq 1 30); do + status="$(docker inspect --format '{{.State.Health.Status}}' "$name")" + if [ "$status" = "healthy" ]; then + echo "container healthy after ${i}s" + break + fi + sleep 1 +done +if [ "$(docker inspect --format '{{.State.Health.Status}}' "$name")" != "healthy" ]; then + echo "container failed to become healthy" + docker logs "$name" + exit 1 +fi + +# SCPI round-trip over the SOCKET gateway (no client framing +# required). Expect the baked default IDN response. +response="$(docker exec "$name" bash -c 'echo "*IDN?" | nc -w 2 127.0.0.1 5025')" +echo "got: $response" +case "$response" in + *IVICLI-MOCK*) + echo "smoke passed" + ;; + *) + echo "smoke FAILED — unexpected response" + docker logs "$name" + exit 1 + ;; +esac + +docker rm -f "$name" diff --git a/docs/adr/0016-cross-platform-policy.md b/docs/adr/0016-cross-platform-policy.md index eb15bde..ddfef77 100644 --- a/docs/adr/0016-cross-platform-policy.md +++ b/docs/adr/0016-cross-platform-policy.md @@ -1,16 +1,96 @@ # 0016. Cross-Platform Policy -- Status: Draft -- Date: 2026-05-19 +- Status: Accepted +- Date: 2026-07-24 ## Context -TBD +ivi-cli ships self-contained and framework-dependent binaries for six +RIDs (`linux-x64`, `linux-arm64`, `win-x64`, `win-arm64`, `osx-x64`, +`osx-arm64`) plus a multi-arch (`linux/amd64` + `linux/arm64`) mock +container ([ADR 0018](0018-deployment-strategy.md)). Until this ADR, +only three of those architectures were ever *executed* before a +release: the publish matrix ran on x64 (and Apple-silicon macOS) +runners, cross-publishing `linux-arm64`, `win-arm64`, and `osx-x64` +without running the result. A publish-side fault visible only at +runtime — a bad RID mapping, a missing native library in the +single-file bundle — would have surfaced on a user's machine, not in +CI. GitHub-hosted native arm64 runners (`ubuntu-24.04-arm`, +`windows-11-arm`) and the Intel macOS runner (`macos-15-intel`) are +now available to public repositories at no cost, which removes the +original reason for cross-publishing. ## Decision -TBD +### 1. Supported platform set + +The supported OS/architecture set **is** the six shipped RIDs. No RID +is shipped without the verification in §2; conversely, adding a RID to +the release matrix implies adding its native verification. + +### 2. Release gate: native execution per RID + +`release.yml`'s publish matrix pairs every RID with a runner of the +**same OS and architecture**: + +| RID | Runner | +| --- | --- | +| `linux-x64` | `ubuntu-latest` | +| `linux-arm64` | `ubuntu-24.04-arm` | +| `win-x64` | `windows-latest` | +| `win-arm64` | `windows-11-arm` | +| `osx-x64` | `macos-15-intel` | +| `osx-arm64` | `macos-latest` | + +Each matrix job builds, runs the unit + architecture test suite, and +**smoke-runs the published self-contained binary** (`ivicli --version` +must succeed and report the tag-derived version). A binary that cannot +start on its target architecture fails the release before anything is +published. + +The framework-dependent binary is published per-RID but not separately +smoke-run: it carries the same IL as the self-contained bundle and its +runtime is supplied by the user. + +### 3. Container: both manifest halves executed + +The mock container's smoke gate runs **natively on both +architectures** before the multi-arch push: amd64 in the `docker` job, +arm64 in `docker-smoke-arm64` on an arm64 runner (shared script +`docker/smoke-test.sh` — HEALTHCHECK + SCPI round-trip). QEMU +emulation is deliberately not used for the smoke: emulated .NET is +slow and flaky, and a native runner exists. + +### 4. PR and nightly scope + +Cross-platform spread is a **release gate, not a PR gate**. PR CI +(`pr.yml`) stays on `ubuntu-latest` only, keeping the feedback loop +fast; `nightly.yml` runs the Integration suite on `ubuntu-latest`, +`windows-latest`, and `macos-latest`. Rationale: the unit suite is +platform-neutral .NET, and the architecture-specific failure modes +observed in practice live in publish/packaging — exactly what §2 +covers on every release. + +### 5. Platform-specific functionality + +The Local backend's `Ivi.Visa` reflection path is **Windows-only** +(the IVI Shared Components have no Linux/macOS distribution). On other +platforms it degrades to a clean `BackendError`, and its +positive-side tests gate on the `ni-visa` prerequisite probe +([ADR 0037](0037-real-hardware-integration-tests.md)). All other +features — gateways, mock/replay, discovery, Management API — are +platform-neutral by construction. ## Consequences -TBD \ No newline at end of file +- Every shipped binary and both container manifest halves have been + executed at least once before users can download them; the + "cross-published but never run" class of release defect is closed. +- The release pipeline uses three additional runner images + (`ubuntu-24.04-arm`, `windows-11-arm`, `macos-15-intel`). All are + free for public repositories, but runner-label retirement (GitHub + rotates macOS/Windows images) will occasionally force a label bump + here and in `release.yml`. +- Release wall-clock changes little: the matrix was already six jobs; + they now differ in host arch rather than duplicating hosts. +- PR feedback speed is unchanged — no PR job was added. diff --git a/docs/adr/0018-deployment-strategy.md b/docs/adr/0018-deployment-strategy.md index 6311b3c..9741de0 100644 --- a/docs/adr/0018-deployment-strategy.md +++ b/docs/adr/0018-deployment-strategy.md @@ -220,11 +220,14 @@ Three smoke gates protect the image quality: `src/IviCli.Cli/**`. Publishes, builds single-arch (amd64), runs HEALTHCHECK + SCPI roundtrip. No push. Status check appears only on relevant PRs. -3. **Release-time** (`.github/workflows/release.yml` `docker` - job): single-arch smoke gate **before** the multi-arch push. - Failure aborts the push so `latest` is never updated to a - broken image. After smoke passes, buildx pushes the multi-arch - manifest with all four tags. +3. **Release-time** (`.github/workflows/release.yml`): native + smoke gates on **both architectures** — amd64 inside the + `docker` job, arm64 in `docker-smoke-arm64` on an arm64 runner + — **before** the multi-arch push. Failure of either aborts the + push so `latest` is never updated to a broken image. After both + smokes pass, buildx pushes the multi-arch manifest with all + four tags. All three gates share `docker/smoke-test.sh` + (HEALTHCHECK + SCPI round-trip). ### 10. Required runtime composition env