fix: harden AppImage certification checks

This commit is contained in:
2026-09-15 10:00:14 +02:00
parent 0fb96da266
commit 2d3169838c
10 changed files with 224 additions and 12 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
- uses: actions/checkout@v4
# lavapipe: software Vulkan for the headless screenshot tests.
# OCCT dev packages: headers + toolkits for the vernier-occt-sys facade.
- run: apt-get update && apt-get install -y --no-install-recommends python3 fish jq mesa-vulkan-drivers libocct-foundation-dev libocct-modeling-data-dev libocct-modeling-algorithms-dev
- run: apt-get update && apt-get install -y --no-install-recommends python3 fish jq patchelf mesa-vulkan-drivers libocct-foundation-dev libocct-modeling-data-dev libocct-modeling-algorithms-dev
- run: rustup component add rustfmt clippy
# The same exact manifest as scripts/check.fish. Its compiling Cargo
# rows explicitly select system-occt; CI never starts a vendored build.
+1 -1
View File
@@ -20,7 +20,7 @@ Branch: `codex/phased-next-steps`
| 12. CI/software renderer | done | task12_ci_renderer | 064e6b7 | approved after review-fix pass; no remaining findings | shared 143-step adapter selection; Python 7/7 and focused golden green; 13.45s representative RADV positive/exact exit-1 control; lavapipe absent and explicitly unverified |
| 13. C++ sanitizer evidence | done with explicit runtime boundary | task13_sanitizers | 3370872 | approved after one documentation correction | 14 generated cxxbridge warnings; no project-owned warning; one sanitizer link attempt failed before tests because runtimes were omitted under -nodefaultlibs; no sanitizer-clean claim or suppression |
| 14. Vendored static OCCT | done | task14_static_occt | 9eae002 | approved after review-fix pass; no remaining findings | official V7_9_3 at a016080b; 26 static archives built once; app/worker have no libTK dynamic deps; 236-step RADV workflow deterministic with STEP/STL/reopen |
| 15. AppImage | source approved; certification pending | task15_appimage | pending source commit | approved after review-fix pass; no remaining source findings | source contracts green; next exactly one detached build, structural smoke, dependency audit and native readback; driven workflows deferred to Task 16 |
| 15. AppImage | built once; certification audit pending | task15_appimage | 0fb96da + pending auditor fix | auditor/integration fix approved; no remaining findings | artifact f02aea20 at 0fb96da; first smoke exposed path-normalization bug; source-only fix approved; no rebuild; corrected smoke/readback pending |
| 16. Integrated verification | pending | controller | — | pending | — |
The MikroB dashboard was unavailable at `localhost:3420`; this branch-local ledger is the execution authority. No dashboard database is mutated directly.
@@ -0,0 +1,149 @@
# Task 15 Stage B certification report
## Outcome
The one authorized AppImage build completed, but certification stopped at the
first structural-smoke failure. The artifact is **built but not certified** and
must not replace the managed local release.
No retry, second build, native readback, driven workflow, full gate,
installation, launcher mutation, commit, or push was performed after the
failure.
## Post-certification auditor fix
The narrow smoke-auditor fix is implemented in the main phased-next-steps
worktree but remains uncommitted and uncertified. `scripts/smoke-appimage.sh`
now canonicalizes both the extracted `usr/lib` root and each absolute path
reported by `ldd` before deciding whether a non-base dependency came from the
AppDir or the host.
A focused synthetic AppDir contract compiles a tiny exception-using C++ ELF with
the already-required `c++` tool, then copies its `libstdc++.so.6` and
`libgcc_s.so.1` dependencies and applies the production `$ORIGIN/../lib` RPATH.
Before the fix it reproduced:
```text
exit=1
appimage smoke: non-base dependency resolved from host: libstdc++.so.6 => /tmp/vernier-appimage-smoke.vLUjNHrB/squashfs-root/usr/bin/../lib/libstdc++.so.6
```
After the fix, the same `usr/bin/../lib` spelling is accepted. The contract then
removes the AppDir copy of `libstdc++.so.6`, regenerates its manifest, and proves
that a genuine host resolution is still refused. The focused source contract
passes; no real AppImage smoke or native readback was rerun.
The packaging source check is now one exact `scope=all` row in the shared
`scripts/acceptance-gate.json`; the direct pre-step was removed from
`scripts/check.fish`, preserving the Task 12 single-manifest local/CI contract.
The focused Python contract first failed on both the duplicate local pre-step and
the absent manifest row, then passed after the integration change. CI now
installs `patchelf`, which both the source contract and packaging toolchain use.
The fixture no longer depends on an ambient `ninja` binary.
The current software-renderer evidence now records 144 selected commands for
either adapter after adding the all-scope packaging row. Historical Task 12
evidence remains unchanged at the 143-command state it measured.
The uncommitted source-fix file set is:
- `.gitea/workflows/ci.yaml`
- `scripts/acceptance-gate.json`
- `scripts/check.fish`
- `scripts/smoke-appimage.sh`
- `scripts/test-acceptance-gate.py`
- `scripts/test-appimage-packaging.sh`
- `docs/SOFTWARE_RENDERER_2026-09-14.md`
- `.superpowers/sdd/task-15-certification-report.md` (this report; excluded
from the source digest below)
The source/test digest (mode, SHA-256, path records sorted with `LC_ALL=C`) is:
```text
9922a8d53bb0971ba4b0fa9624c58214c13be1ebb179b8433d6fb41e12ad0366
```
Certification therefore remains pending a new immutable source commit, one
new artifact build, structural smoke, and native readback. The artifact recorded
below was built before this uncommitted auditor fix and remains uncertified.
## Immutable inputs
- Packaging-source revision:
`0fb96da2668a7bf75b3044bb15f9e98bc373e8fa`
- Detached certification worktree:
`/home/nilsb/Documents/Projects/VernierCAD/target/worktrees/phased-next-steps/target/worktrees/task15-certification-0fb96da2`
- Final worktree state: detached HEAD, zero status rows
- Initialized OCCT submodule revision:
`a016080bf6738d6aeae020badee4e888ad1540a5`
- Reused prepared OCCT root:
`/home/nilsb/Documents/Projects/VernierCAD/target/worktrees/phased-next-steps/target/vendored-occt`
- Prepared OCCT validation: exact completion/source records, 17 direct toolkits,
26 closure archives, platform list, six required resource files, two license
files, and no shared `libTK*.so*` all passed before the build. OCCT was not
rebuilt.
The explicit shared tool cache was absent before this run and populated by one
fetch invocation:
```text
appimagetool-x86_64.AppImage
ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0
runtime-x86_64
1cc49bcf1e2ccd593c379adb17c9f85a36d619088296504de95b1d06215aebbf
```
Cache path:
`/home/nilsb/Documents/Projects/VernierCAD/target/appimage-tools-task15`
## Single build evidence
The builder was invoked once with version `0.0.4-alpha`, revision equal to the
detached HEAD, the validated prepared OCCT root, and the explicit shared tool
cache. Cargo completed the release build in 40.55 seconds and appimagetool
reported success. The optional AppStream metadata warning and the known generated
`cxx` maybe-uninitialized warnings were present; neither stopped the build.
- AppImage:
`/home/nilsb/Documents/Projects/VernierCAD/target/worktrees/phased-next-steps/target/worktrees/task15-certification-0fb96da2/target/appimage/VernierCAD-0.0.4-alpha-x86_64.AppImage`
- AppImage SHA-256:
`f02aea202b32c6aa91f4cc61712f662f3e036090848dc98184d4921cd53c1f35`
- Sidecar row: exact same hash and basename
`VernierCAD-0.0.4-alpha-x86_64.AppImage`
- Size: `43,964,920` bytes
- Mode: `0755`
- File identity: x86-64 static-PIE AppImage runtime ELF
- `vernier-app` SHA-256:
`9dce882ddf746de123b47874e8ed16252e795e35ded265bd55567d07500a7ca6`
- `vernier-worker` SHA-256:
`d311a6a8ce01332a8f2768e3a711a5bd1e754d98bbf18fe310c7b10714b6beb0`
- Build log:
`/home/nilsb/Documents/Projects/VernierCAD/target/task15-appimage-build.log`
- Build-log SHA-256:
`27d2ed2320f74fbfce7491ecee187445d4b769acb30476bb933f449423194c8f`
## First failure and stop boundary
The structural smoke was invoked once with expected version `0.0.4-alpha` and
the exact detached revision. It exited 1 with:
```text
appimage smoke: non-base dependency resolved from host: libstdc++.so.6 => /tmp/vernier-appimage-smoke.uFtmpINb/squashfs-root/usr/bin/../lib/libstdc++.so.6
```
Smoke log:
`/home/nilsb/Documents/Projects/VernierCAD/target/task15-appimage-smoke.log`
Smoke-log SHA-256:
`448717b686df678ade9fb956d69e55b2c24d9005f45180515ec7f7fb90904f7f`
The reported path lexically contains `usr/bin/../lib`; after normalization it
would be inside the extracted AppDir. The smoke compares the unnormalized `ldd`
spelling to the canonical AppDir library prefix, so it refused before completing
the full app/worker dependency audit. This is evidence of the exact first
failure, not a passing dependency claim.
Because the instructions required stopping at the first fetch/build/smoke
failure, native `VERNIER_READBACK` was not attempted. Stage B therefore remains
incomplete pending a separately authorized source fix and new immutable build.
+3 -3
View File
@@ -4,9 +4,9 @@
`scripts/acceptance-gate.json` is the single `vernier.acceptance.v1` step
list consumed by both `scripts/check.fish` and `.gitea/workflows/ci.yaml`.
The manifest has 143 pre-existing gate commands plus four software variants
of the golden-bearing workflows. A RADV selection and a software selection
each execute 143 steps. All eight negative controls declare exact expected
The manifest now selects 144 commands for either adapter. Four golden-bearing
workflows have parallel RADV/software rows, and each adapter selects only its
matching row. All eight negative controls declare exact expected
exit 1; the runner treats every other non-zero status as an unexpected gate
failure and retains each command's stdout, stderr, duration, and output tree.
Runner evidence is written under `evidence/<step>/`; `{out}` expands to the
@@ -894,7 +894,7 @@ scripts/smoke-appimage.sh --version 0.0.4-alpha --revision "$release_sha" target
Expected: the artifact is absent and the smoke exits non-zero with a named missing-artifact error.
- [ ] **Step 3: Pin and fetch the packaging tools**
- [x] **Step 3: Pin and fetch the packaging tools**
Record and verify exactly these two inputs in `packaging/appimage/tools.sha256`:
+10
View File
@@ -24,6 +24,16 @@
"adapter": "none",
"artifact": "gate-step-contract"
},
{
"id": "appimage-packaging-source-contract",
"scope": "all",
"argv": [
"scripts/test-appimage-packaging.sh"
],
"expected_exit": 0,
"adapter": "none",
"artifact": "appimage-packaging-source-contract"
},
{
"id": "clippy",
"scope": "all",
-2
View File
@@ -2,6 +2,4 @@
# Full release gate. The manifest is the single step list consumed locally
# and by CI; its Cargo rows explicitly select the fast system-occt path.
# Task 16 runs this complete selection once before installation.
scripts/test-appimage-packaging.sh
or exit $status
python3 scripts/run-acceptance-gate.py --adapter RADV
+5 -1
View File
@@ -67,6 +67,9 @@ trap 'rm -rf "$scratch"' EXIT
) || die "extract-and-run extraction failed"
root=$scratch/squashfs-root
[[ -d "$root" ]] || die "extract-and-run did not create squashfs-root"
root=$(readlink -f -- "$root")
[[ -d "$root/usr/lib" ]] || die "missing AppDir usr/lib"
app_lib=$(readlink -f -- "$root/usr/lib")
mapfile -t appruns < <(find "$root" -name AppRun -type f -perm /111 -print)
(( ${#appruns[@]} == 1 )) || die "expected exactly one executable AppRun, found ${#appruns[@]}"
@@ -157,7 +160,8 @@ for binary in "$app" "$worker"; do
while IFS=$'\t' read -r soname resolved; do
[[ -n "$soname" && -n "$resolved" ]] || continue
is_base_runtime "$soname" && continue
[[ "$resolved" == "$root/usr/lib/"* ]] || die "non-base dependency resolved from host: $soname => $resolved"
resolved=$(readlink -f -- "$resolved") || die "could not canonicalize dependency path: $soname"
[[ "$resolved" == "$app_lib/"* ]] || die "non-base dependency resolved from host: $soname => $resolved"
printf '%s\n' "$soname" >>"$resolved_nonbase"
done < <(awk '$2 == "=>" && $3 ~ /^\// { print $1 "\t" $3 }' <<<"$ldd_output")
done
+22
View File
@@ -28,9 +28,31 @@ class AcceptanceManifestTests(unittest.TestCase):
invocation = "scripts/run-acceptance-gate.py"
self.assertIn(invocation, local)
self.assertIn(invocation, ci)
self.assertNotIn("scripts/test-appimage-packaging.sh", local)
self.assertNotIn("target/release/vernier-drive scripts/drive/", local)
self.assertNotIn("cargo run --locked -p vernier-drive", ci)
def test_appimage_source_contract_is_one_all_scope_manifest_step(self):
expected = {
"id": "appimage-packaging-source-contract",
"scope": "all",
"argv": ["scripts/test-appimage-packaging.sh"],
"expected_exit": 0,
"adapter": "none",
"artifact": "appimage-packaging-source-contract",
}
matches = [
step
for step in self.manifest["steps"]
if "scripts/test-appimage-packaging.sh" in step["argv"]
]
self.assertEqual(matches, [expected])
ci = (ROOT / ".gitea" / "workflows" / "ci.yaml").read_text(
encoding="utf-8"
)
self.assertIn(" patchelf ", ci)
def test_manifest_is_exact_and_fail_closed(self):
self.assertEqual(set(self.manifest), {"schema", "steps"})
self.assertEqual(self.manifest["schema"], "vernier.acceptance.v1")
+32 -3
View File
@@ -121,8 +121,31 @@ fixture=$scratch/fixture
mkdir -p "$fixture/usr/bin" "$fixture/usr/share/vernier"
cp packaging/appimage/AppRun "$fixture/AppRun"
cp packaging/appimage/vernier.desktop "$fixture/vernier.desktop"
cp /usr/bin/true "$fixture/usr/bin/vernier-app"
cp /usr/bin/true "$fixture/usr/bin/vernier-worker"
cxx=$(command -v c++) || die "c++ is required for the synthetic dependency fixture"
command -v patchelf >/dev/null || die "patchelf is required for the synthetic dependency fixture"
cat >"$scratch/libstdcpp-fixture.cc" <<'EOF'
#include <iostream>
#include <stdexcept>
int main() {
try {
throw std::runtime_error("VernierCAD packaging fixture");
} catch (const std::exception& error) {
std::cout << error.what();
}
}
EOF
"$cxx" -O0 "$scratch/libstdcpp-fixture.cc" -o "$scratch/libstdcpp-fixture"
cp "$scratch/libstdcpp-fixture" "$fixture/usr/bin/vernier-app"
cp "$scratch/libstdcpp-fixture" "$fixture/usr/bin/vernier-worker"
mkdir "$fixture/usr/lib"
for soname in libstdc++.so.6 libgcc_s.so.1; do
resolved=$(ldd "$scratch/libstdcpp-fixture" | awk -v soname="$soname" '$1 == soname { print $3 }')
[[ -f "$resolved" ]] || die "could not resolve $soname for the synthetic dependency fixture"
cp -L "$resolved" "$fixture/usr/lib/$soname"
done
patchelf --set-rpath '$ORIGIN/../lib' "$fixture/usr/bin/vernier-app"
patchelf --set-rpath '$ORIGIN/../lib' "$fixture/usr/bin/vernier-worker"
fixture_version=0.0.4-alpha
fixture_revision=0123456789abcdef0123456789abcdef01234567
fixture_artifact=$scratch/VernierCAD-$fixture_version-x86_64.AppImage
@@ -177,6 +200,13 @@ expect_smoke_failure 'usage: scripts/smoke-appimage.sh --version VERSION --revis
VERNIER_FAKE_APPDIR=$fixture scripts/smoke-appimage.sh \
--version "$fixture_version" --revision "$fixture_revision" "$fixture_artifact" >/dev/null
mv "$fixture/usr/lib/libstdc++.so.6" "$scratch/libstdc++.so.6"
make_fixture_manifest
expect_smoke_failure 'appimage smoke: non-base dependency resolved from host: libstdc++.so.6 =>' \
--version "$fixture_version" --revision "$fixture_revision" "$fixture_artifact"
mv "$scratch/libstdc++.so.6" "$fixture/usr/lib/libstdc++.so.6"
make_fixture_manifest
write_fixture_sidecar other.AppImage
expect_smoke_failure 'appimage smoke: artifact checksum filename mismatch' \
--version "$fixture_version" --revision "$fixture_revision" "$fixture_artifact"
@@ -220,6 +250,5 @@ require_literal docs/PORTABLE_LINUX_RELEASE.md 'APPIMAGE_EXTRACT_AND_RUN=1'
require_literal docs/PORTABLE_LINUX_RELEASE.md 'No FUSE'
require_literal docs/PORTABLE_LINUX_RELEASE.md 'Task 16'
require_literal docs/PORTABLE_LINUX_RELEASE.md '--version 0.0.4-alpha --revision "$release_sha"'
require_literal scripts/check.fish 'scripts/test-appimage-packaging.sh'
echo "AppImage packaging source contracts passed"