Skip to content

Commit 886582f

Browse files
authored
QVAC-23651: harden and test fabric-stack CI actions (#3849)
* infra: add reusable fabric-stack CI actions * fix: harden and test fabric-stack CI actions * fix: bind fabric detection to the event head SHA * fix: close fabric-stack CI validation gaps * fix: bind fabric prebuilds to the producing PR build Detection matched any packages/fabric path, so docs, release notes and tests claimed a prebuild the producer never makes and left consumers polling until timeout. Scope it to build inputs, mirroring detect-native-changes so the two predicates stay in lockstep. The download step selected a run by head SHA alone, but any run reachable under the same workflow can carry that SHA — including one whose base branch was tampered with, since the feature-* and tmp-* namespaces carry no ruleset protections. Bind the run by head_sha, workflow path, event, head repository and PR association before trusting its artifact. Validate the artifact tree before copying: reject symlinks and non-regular files anywhere, and allow only .cmake files under share/. Requiring share/ to exist mandated the payload that find_package includes as CMake at configure time without checking what was in it. Fix a dead poll filter while here: in_progress is a status value, so matching it against conclusion made consumers wait for the whole producer run instead of the upload. * fix: detect fabric changes from the merge base github.event.pull_request.base.sha is the base branch tip at event time, not the branch point, so diffing against it also reported — in reverse — everything that landed on the base branch since. Any PR that merely stayed open while a fabric commit merged flipped to fabric_stack=true and then waited out timeout-minutes for a prebuild on-pr-fabric never produced for its head SHA, re-firing on every synchronize. Three-dot alone does not fix it: consumers check out with the default fetch-depth of 1, and a shallow clone has no common history, so base...head exits 128 and takes the fail-safe — turning an occasional false positive into a universal one. Resolve the merge base through the compare API instead, fetch that single commit, and diff merge-base to head. Falls back to a local git merge-base when no token is available, and fails safe rather than silently degrading to the base tip. Also select producing runs on status rather than conclusion, so a run that has already published the artifact is eligible before the tests behind it conclude. The detection assertion hard-coded false for every pull_request, measuring the repository's current state instead of the predicate, and asserted true on workflow_dispatch against a path that returns before any logic runs. The tests now drive the diff through mock-git and mock-gh: unrelated changes, a fabric path last in a multi-line diff, a qvac-fabric overlay port change, the compare-API fallback, and an unresolvable merge base. Drift itself is asserted directly — the recorded git diff must name the merge base and must not name base.sha. * fix: parse test SHA as strings instead of numbers
1 parent ad1eab1 commit 886582f

6 files changed

Lines changed: 1137 additions & 0 deletions

File tree

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
name: 'Detect fabric stack'
2+
description: >
3+
Read-only detection for fabric-stack PRs. Sets fabric_stack=true when the PR's
4+
own diff — merge base to head, never the base branch tip — touches the
5+
qvac-fabric overlay port or a packages/fabric file that changes the built
6+
artifact. Used to disable stale prebuild reuse and to wire npm-runtime
7+
consumers to PR-built @qvac/fabric prebuilds. The predicate must stay in
8+
lockstep with detect-native-changes: claiming a prebuild the producer never
9+
makes leaves the consumer polling until timeout.
10+
11+
inputs:
12+
github-token:
13+
description: >
14+
Token with contents:read, used to resolve the merge base through the
15+
compare API. Callers that check out with fetch-depth 0 may pass an empty
16+
string and rely on the local git merge-base fallback.
17+
required: false
18+
default: ${{ github.token }}
19+
20+
outputs:
21+
fabric_stack:
22+
description: 'true when this PR is a fabric-stack change'
23+
value: ${{ steps.detect.outputs.fabric_stack }}
24+
25+
runs:
26+
using: composite
27+
steps:
28+
- name: Detect fabric-stack paths
29+
id: detect
30+
shell: bash
31+
env:
32+
EVENT_NAME: ${{ github.event_name }}
33+
BASE_SHA: ${{ github.event.pull_request.base.sha }}
34+
BASE_REPO: ${{ github.event.pull_request.base.repo.full_name }}
35+
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
36+
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
37+
REPO: ${{ github.repository }}
38+
GH_TOKEN: ${{ inputs.github-token }}
39+
run: |
40+
set -euo pipefail
41+
42+
if [ "$EVENT_NAME" = "workflow_dispatch" ] || \
43+
[ "$EVENT_NAME" = "workflow_call" ] || \
44+
[ "$EVENT_NAME" = "push" ]; then
45+
echo "Trusted event ($EVENT_NAME) — treating as fabric-stack"
46+
echo "fabric_stack=true" >> "$GITHUB_OUTPUT"
47+
exit 0
48+
fi
49+
50+
FETCHED=false
51+
if [ -n "$HEAD_SHA" ]; then
52+
if git fetch origin "$HEAD_SHA" --depth=1 2>/dev/null; then
53+
FETCHED=true
54+
elif [ -n "$HEAD_REPO" ]; then
55+
echo "SHA not fetchable from origin (likely fork PR) — trying exact SHA from $HEAD_REPO"
56+
if git fetch "https://github.com/${HEAD_REPO}.git" "$HEAD_SHA" --depth=1 2>/dev/null; then
57+
FETCHED_SHA=$(git rev-parse FETCH_HEAD)
58+
if [ "$FETCHED_SHA" = "$HEAD_SHA" ]; then
59+
FETCHED=true
60+
else
61+
echo "::warning::Fetched SHA $FETCHED_SHA does not match event SHA $HEAD_SHA"
62+
fi
63+
fi
64+
fi
65+
fi
66+
67+
if [ "$FETCHED" != "true" ]; then
68+
echo "::warning::Could not fetch PR head — treating as fabric-stack (safe default)"
69+
echo "fabric_stack=true" >> "$GITHUB_OUTPUT"
70+
exit 0
71+
fi
72+
73+
# base.sha is the base branch tip at event time, not the branch point, so
74+
# diffing against it also reports — in reverse — everything that landed
75+
# on the base branch since. Resolve the merge base instead. `A...B` is
76+
# not an option: consumers check out with the default fetch-depth of 1,
77+
# and a shallow clone has no common history to walk, so three-dot exits
78+
# 128 and every PR would take the fail-safe below.
79+
MERGE_BASE=""
80+
if [ -n "$BASE_SHA" ] && [ -n "$HEAD_SHA" ]; then
81+
if [ -n "${GH_TOKEN:-}" ]; then
82+
MERGE_BASE=$(gh api "repos/${REPO}/compare/${BASE_SHA}...${HEAD_SHA}" \
83+
--jq '.merge_base_commit.sha' 2>/dev/null || true)
84+
fi
85+
if [ -z "$MERGE_BASE" ]; then
86+
MERGE_BASE=$(git merge-base "$BASE_SHA" "$HEAD_SHA" 2>/dev/null || true)
87+
fi
88+
fi
89+
90+
if [[ ! "$MERGE_BASE" =~ ^[0-9a-f]{40}$ ]]; then
91+
echo "::warning::Could not resolve a merge base for $BASE_SHA...$HEAD_SHA — treating as fabric-stack (safe default)"
92+
echo "fabric_stack=true" >> "$GITHUB_OUTPUT"
93+
exit 0
94+
fi
95+
96+
if ! git cat-file -e "$MERGE_BASE" 2>/dev/null; then
97+
if ! git fetch origin "$MERGE_BASE" --depth=1 2>/dev/null; then
98+
if [ -n "$BASE_REPO" ]; then
99+
git fetch "https://github.com/${BASE_REPO}.git" "$MERGE_BASE" --depth=1 2>/dev/null || true
100+
fi
101+
fi
102+
fi
103+
104+
STACK=false
105+
DIFF_FILES=$(git diff --name-only "$MERGE_BASE" "$HEAD_SHA" -- 2>&1) || {
106+
echo "::warning::git diff failed — treating as fabric-stack"
107+
echo "fabric_stack=true" >> "$GITHUB_OUTPUT"
108+
exit 0
109+
}
110+
if [ -n "$DIFF_FILES" ]; then
111+
# Only paths that change the built artifact. A `case` glob's `*`
112+
# spans `/`, so `packages/fabric/*.cpp` also covers `addon/`.
113+
# Docs, release notes and tests are deliberately excluded: they
114+
# never produce a prebuild for the consumer to wait on.
115+
while IFS= read -r file; do
116+
case "$file" in
117+
vcpkg-overlays/ports/qvac-fabric/*) STACK=true; break ;;
118+
packages/fabric/*.cpp|packages/fabric/*.hpp|packages/fabric/*.c|packages/fabric/*.h) STACK=true; break ;;
119+
packages/fabric/*CMakeLists.txt) STACK=true; break ;;
120+
packages/fabric/cmake/*) STACK=true; break ;;
121+
packages/fabric/vcpkg.json|packages/fabric/vcpkg-configuration.json) STACK=true; break ;;
122+
packages/fabric/binding.js|packages/fabric/exports.txt|packages/fabric/symbols.map) STACK=true; break ;;
123+
esac
124+
done <<< "$DIFF_FILES"
125+
fi
126+
127+
echo "Merge base: $MERGE_BASE"
128+
echo "Fabric stack: $STACK"
129+
echo "fabric_stack=$STACK" >> "$GITHUB_OUTPUT"
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
name: 'Overlay local @qvac/fabric'
2+
description: >
3+
Copy PR-built fabric prebuilds into every installed node_modules/@qvac/fabric
4+
tree under the consumer workdir (and optional extra search roots). Must run
5+
after npm install and before bare-make generate so find_package(qvac-fabric)
6+
resolves headers from the overlaid tree.
7+
8+
inputs:
9+
workdir:
10+
description: 'Consumer package directory, e.g. packages/classification-ggml'
11+
required: true
12+
prebuilds-root:
13+
description: 'Path to the merged fabric prebuilds tree (include/, share/, <platform>-<arch>/)'
14+
required: true
15+
platform:
16+
description: 'When set with arch, overlay only this platform directory'
17+
required: false
18+
default: ''
19+
arch:
20+
description: 'Platform arch suffix (x64, arm64, ...) paired with platform'
21+
required: false
22+
default: ''
23+
tags:
24+
description: 'Optional platform suffix (e.g. -simulator for ios-*-simulator jobs)'
25+
required: false
26+
default: ''
27+
search-dirs:
28+
description: 'Extra directories to search for node_modules/@qvac/fabric (space-separated)'
29+
required: false
30+
default: ''
31+
32+
runs:
33+
using: composite
34+
steps:
35+
- name: Overlay PR fabric prebuilds into @qvac/fabric
36+
shell: bash
37+
env:
38+
WORKDIR: ${{ inputs.workdir }}
39+
SRC: ${{ inputs.prebuilds-root }}
40+
PLATFORM: ${{ inputs.platform }}
41+
ARCH: ${{ inputs.arch }}
42+
TAGS: ${{ inputs.tags }}
43+
SEARCH_DIRS: ${{ inputs.search-dirs }}
44+
run: |
45+
set -euo pipefail
46+
if [ ! -d "$SRC" ]; then
47+
echo "::error::Fabric prebuilds root does not exist: $SRC"
48+
exit 1
49+
fi
50+
if [ ! -d "$SRC/include" ] || [ ! -d "$SRC/share" ]; then
51+
echo "::error::Fabric prebuilds must contain both include/ and share/: $SRC"
52+
exit 1
53+
fi
54+
55+
# share/ becomes the find_package(qvac-fabric CONFIG) search path, so its
56+
# contents are include()d as CMake at configure time. Validate the shape
57+
# of the tree before anything is copied: reject symlinks and non-regular
58+
# files anywhere, and allow only .cmake files under share/. Recursive
59+
# `find` is avoided here for the same reason as below (Git Bash resolves
60+
# it to Windows find.exe).
61+
if ! node -e '
62+
const fs = require("node:fs")
63+
const path = require("node:path")
64+
65+
function validate(directory, relative) {
66+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
67+
const child = relative ? `${relative}/${entry.name}` : entry.name
68+
69+
if (entry.isSymbolicLink()) {
70+
throw new Error(`symlink in fabric prebuilds: ${child}`)
71+
}
72+
if (entry.isDirectory()) {
73+
validate(path.join(directory, entry.name), child)
74+
continue
75+
}
76+
if (!entry.isFile()) {
77+
throw new Error(`non-regular file in fabric prebuilds: ${child}`)
78+
}
79+
if (child.startsWith("share/") && !entry.name.endsWith(".cmake")) {
80+
throw new Error(`non-cmake file under share/: ${child}`)
81+
}
82+
}
83+
}
84+
85+
validate(process.argv[1], "")
86+
' "$SRC"; then
87+
echo "::error::Fabric prebuilds failed content validation: $SRC"
88+
exit 1
89+
fi
90+
91+
WORK_ABS="$(cd "$WORKDIR" && pwd)"
92+
WS_ABS="$(cd "$GITHUB_WORKSPACE" && pwd)"
93+
SEARCH="$WORK_ABS $WS_ABS"
94+
if [ -n "$SEARCH_DIRS" ]; then
95+
SEARCH="$SEARCH $SEARCH_DIRS"
96+
fi
97+
98+
# Check each explicit search root directly. Recursive `find` is avoided:
99+
# Git Bash can resolve it to Windows find.exe instead of GNU find.
100+
discover_fabric_packages() {
101+
local search_root="$1"
102+
[ -d "$search_root" ] || return 0
103+
104+
local direct="$search_root/node_modules/@qvac/fabric"
105+
if [ -d "$direct" ]; then
106+
echo "$direct"
107+
fi
108+
}
109+
110+
overlay_tree() {
111+
local dest_root="$1"
112+
mkdir -p "$dest_root"
113+
114+
rm -rf "$dest_root/include"
115+
cp -r "$SRC/include" "$dest_root/"
116+
rm -rf "$dest_root/share"
117+
cp -r "$SRC/share" "$dest_root/"
118+
119+
if [ -n "$PLATFORM" ] && [ -n "$ARCH" ]; then
120+
plat_key="${PLATFORM}-${ARCH}${TAGS}"
121+
plat_dir="$SRC/${plat_key}"
122+
if [ ! -d "$plat_dir" ]; then
123+
echo "::error::No '${plat_key}/' in fabric prebuilds at $SRC"
124+
ls -la "$SRC" 2>/dev/null || true
125+
exit 1
126+
fi
127+
dest_plat="$dest_root/${plat_key}"
128+
rm -rf "$dest_plat"
129+
mkdir -p "$dest_plat"
130+
cp -r "$plat_dir/." "$dest_plat/"
131+
return
132+
fi
133+
134+
for plat_dir in "$SRC"/*-*; do
135+
[ -d "$plat_dir" ] || continue
136+
base=$(basename "$plat_dir")
137+
case "$base" in
138+
include|share) continue ;;
139+
esac
140+
dest_plat="$dest_root/$base"
141+
rm -rf "$dest_plat"
142+
mkdir -p "$dest_plat"
143+
cp -r "$plat_dir/." "$dest_plat/"
144+
done
145+
}
146+
147+
# Portable bash (macOS /bin/bash is 3.2 — no mapfile).
148+
found_pkg=false
149+
while IFS= read -r pkg; do
150+
[ -z "$pkg" ] && continue
151+
found_pkg=true
152+
dest="$pkg/prebuilds"
153+
echo "Overlaid PR fabric prebuilds into $dest"
154+
overlay_tree "$dest"
155+
node -e '
156+
const fs = require("node:fs")
157+
const path = require("node:path")
158+
159+
function renameArtifacts(root) {
160+
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
161+
const source = path.join(root, entry.name)
162+
if (entry.isDirectory()) renameArtifacts(source)
163+
if (!entry.name.startsWith("tetherto_")) continue
164+
165+
const target = path.join(root, `qvac_${entry.name.slice(9)}`)
166+
fs.renameSync(source, target)
167+
}
168+
}
169+
170+
renameArtifacts(process.argv[1])
171+
' "$dest"
172+
done < <(
173+
for search_root in $SEARCH; do
174+
discover_fabric_packages "$search_root"
175+
done | awk '!seen[$0]++'
176+
)
177+
178+
if [ "$found_pkg" != "true" ]; then
179+
echo "::error::No installed @qvac/fabric found under $SEARCH"
180+
for search_root in $SEARCH; do
181+
if [ -d "$search_root/node_modules/@qvac" ]; then
182+
echo "Contents of $search_root/node_modules/@qvac:"
183+
ls -la "$search_root/node_modules/@qvac" 2>/dev/null || true
184+
fi
185+
done
186+
exit 1
187+
fi

0 commit comments

Comments
 (0)