99name : Integration Tests
1010
1111jobs :
12- ci :
12+ # Standard integration path on GitHub-hosted runners.
13+ # Runs on both Ubuntu and macOS.
14+ integration_github_runner :
15+ name : integration-github-runner (${{ matrix.os }})
1316 runs-on : ${{ matrix.os }}
1417 strategy :
1518 matrix :
1619 os :
1720 - ubuntu-latest
1821 - macos-latest
19- include :
20- - os : ubuntu-latest
21- target : x86_64-unknown-linux-gnu
2222
2323 steps :
2424 - name : Checkout repository
@@ -40,4 +40,223 @@ jobs:
4040
4141 - name : Integration Tests
4242 run : |
43- RUST_BACKTRACE=1 RUST_LOG=debug cargo nextest run --manifest-path=integration-tests/Cargo.toml --nocapture
43+ RUST_BACKTRACE=1 RUST_LOG=debug cargo nextest run --manifest-path=integration-tests/Cargo.toml --nocapture
44+
45+ # High-performance integration path on the casa21 self-hosted runner.
46+ # Required for pushes and trusted PR merge gating.
47+ # Trust boundary for self-hosted execution:
48+ # - always run on push to main
49+ # - on pull_request, only run for authors whose association is MEMBER/OWNER
50+ # (strict org-membership gate; COLLABORATOR is intentionally excluded)
51+ integration_casa21_runner :
52+ name : integration-casa21-runner
53+ if : >
54+ github.event_name == 'push' ||
55+ (github.event_name == 'pull_request' &&
56+ contains(fromJSON('["MEMBER","OWNER"]'), github.event.pull_request.author_association))
57+ runs-on :
58+ - self-hosted
59+ - Linux
60+ - X64
61+ - casa21
62+ steps :
63+ - name : Checkout repository
64+ uses : actions/checkout@v4
65+
66+ - name : Add user cargo bin to PATH
67+ run : echo "/home/sri/.cargo/bin" >> "$GITHUB_PATH"
68+
69+ - name : Verify preinstalled Rust toolchain
70+ run : |
71+ # Casa21 runner is expected to have Rust preinstalled (managed out-of-band on the VPS).
72+ rustc --version
73+ cargo --version
74+ rustup show active-toolchain
75+ rustup run 1.85.0 rustc --version | grep -q "1.85.0"
76+
77+ - name : Verify preinstalled cargo-nextest
78+ run : |
79+ # Casa21 runner is expected to have cargo-nextest preinstalled.
80+ cargo nextest --version
81+ cargo nextest --version | grep -q "0.9.100"
82+
83+ - name : Cleanup stale test processes and temp data
84+ run : |
85+ # Self-hosted runners are persistent. Clean leftovers from prior runs.
86+ pkill -f "[b]itcoin-node|[s]v2-tp" || true
87+ rm -rf /tmp/sv2-integration-tests
88+
89+ - name : Integration Tests
90+ env :
91+ # Use 24 build jobs to parallelize first-build compilation on casa21.
92+ CARGO_BUILD_JOBS : 24
93+ # Reuse build artifacts across runs on the persistent self-hosted runner.
94+ CARGO_TARGET_DIR : /home/sri/.cache/sv2-apps/target-integration-tests
95+ run : |
96+ # Tests are auto-discovered from integration-tests/tests; contributors do not need workflow edits.
97+ # NOTE: do not pass --nocapture with nextest; it serializes test execution.
98+ RUST_BACKTRACE=1 RUST_LOG=info cargo nextest run --manifest-path=integration-tests/Cargo.toml --config-file=integration-tests/.config/nextest.casa21.toml
99+
100+ # Single branch-protection target that implements the full truth table.
101+ # Configure branch protection to require ONLY this check.
102+ #
103+ # Truth table (PR context):
104+ # | integration_github_runner | integration_casa21_runner | PR blocked? |
105+ # |---------------------------|----------------------------------|-------------|
106+ # | passed ✅ | passed ✅ | unblocked ✅ |
107+ # | failed ❌ | passed ✅ | unblocked ✅ |
108+ # | running 💫 | passed ✅ | unblocked ✅ |
109+ # | passed ✅ | failed ❌ | blocked ❌ |
110+ # | failed ❌ | failed ❌ | blocked ❌ |
111+ # | running 💫 | failed ❌ | blocked ❌ |
112+ # | passed ✅ | absent (PR is not from org member) | unblocked ✅ |
113+ # | failed ❌ | absent (PR is not from org member) | blocked ❌ |
114+ # | running 💫 | absent (PR is not from org member) | waiting 💫 |
115+ #
116+ # Why this polls via API instead of `needs` fan-in:
117+ # - with `needs`, GitHub keeps the final job pending until *all* upstream jobs settle,
118+ # even when one path is irrelevant for the current trust mode.
119+ # - this implementation gates only on the relevant path:
120+ # * push / trusted PR -> integration-casa21-runner
121+ # * untrusted PR -> both integration-github-runner matrix jobs
122+ integration_required :
123+ name : integration-required
124+ if : always()
125+ runs-on : ubuntu-latest
126+ # Minimum token scope for the github-script gate:
127+ # - actions:read -> list jobs in this workflow run via Actions API
128+ # - contents:read -> baseline read access for repository metadata/content APIs
129+ permissions :
130+ actions : read
131+ contents : read
132+ steps :
133+ - name : Enforce integration truth-table gate
134+ uses : actions/github-script@v7
135+ with :
136+ script : |
137+ // This is the single required status check used by branch protection.
138+ //
139+ // Design goals:
140+ // 1) Select the relevant integration path based on trust mode.
141+ // 2) Pass/fail/wait exactly as defined by the truth table above.
142+ // 3) Avoid `needs` fan-in deadlocks on irrelevant jobs.
143+ //
144+ // Trust mode:
145+ // - trusted: push events, or PRs authored by MEMBER/OWNER
146+ // - untrusted: all other PRs
147+ //
148+ // Required path by mode:
149+ // - trusted -> integration-casa21-runner (single job)
150+ // - untrusted -> integration-github-runner matrix (ubuntu + macos)
151+
152+ // Associations that are considered trusted for self-hosted execution.
153+ const trustedAssociations = ['MEMBER', 'OWNER'];
154+
155+ // Detect trust mode from event payload.
156+ const trusted =
157+ context.eventName === 'push' ||
158+ (context.eventName === 'pull_request' &&
159+ trustedAssociations.includes(
160+ context.payload.pull_request?.author_association || ''
161+ ));
162+
163+ // Canonical job names as shown in the Actions UI for matrix legs.
164+ // Keep these in sync with `integration_github_runner.name`.
165+ const githubMatrixJobs = [
166+ 'integration-github-runner (ubuntu-latest)',
167+ 'integration-github-runner (macos-latest)',
168+ ];
169+
170+ // Canonical job name for trusted path.
171+ const casa21JobName = 'integration-casa21-runner';
172+
173+ // Human-readable target used in logs.
174+ const targetLabel = trusted
175+ ? casa21JobName
176+ : githubMatrixJobs.join(', ');
177+
178+ // Polling parameters:
179+ // - interval: how often to refresh job states
180+ // - timeout: hard stop to avoid an infinite wait on API/job anomalies
181+ const timeoutMs = 45 * 60 * 1000;
182+ const intervalMs = 10 * 1000;
183+ const start = Date.now();
184+
185+ core.info(`Gating merge status on path: ${targetLabel}`);
186+
187+ while (true) {
188+ // Fetch jobs for THIS workflow run.
189+ // We poll instead of wiring `needs` so this gate can ignore irrelevant paths.
190+ const { data } = await github.rest.actions.listJobsForWorkflowRun({
191+ owner: context.repo.owner,
192+ repo: context.repo.repo,
193+ run_id: context.runId,
194+ per_page: 100,
195+ });
196+
197+ if (trusted) {
198+ // Trusted path semantics:
199+ // - Pass only when casa21 is completed+success.
200+ // - Fail immediately on any non-success completion.
201+ // - Wait while in-progress or not yet visible.
202+ const job = data.jobs.find((j) => j.name === casa21JobName);
203+
204+ if (job) {
205+ core.info(`Observed ${casa21JobName}: status=${job.status}, conclusion=${job.conclusion}`);
206+
207+ if (job.status === 'completed') {
208+ if (job.conclusion === 'success') {
209+ core.info(`Passing: ${casa21JobName}=success`);
210+ return;
211+ }
212+
213+ core.setFailed(`Blocking: ${casa21JobName}=${job.conclusion}`);
214+ return;
215+ }
216+ } else {
217+ // Eventual consistency in job listing can briefly hide newly-started jobs.
218+ core.info(`Job ${casa21JobName} not visible yet; waiting...`);
219+ }
220+ } else {
221+ // Untrusted path semantics:
222+ // - Require BOTH github matrix legs.
223+ // - Fail fast if any leg completes with non-success.
224+ // - Pass only when all legs are completed+success.
225+ // - Otherwise keep waiting.
226+ const jobs = githubMatrixJobs.map((name) => data.jobs.find((j) => j.name === name));
227+ const allPresent = jobs.every(Boolean);
228+
229+ if (!allPresent) {
230+ core.info('Not all integration-github-runner matrix jobs are visible yet; waiting...');
231+ } else {
232+ for (const job of jobs) {
233+ core.info(`Observed ${job.name}: status=${job.status}, conclusion=${job.conclusion}`);
234+ }
235+
236+ const failedJob = jobs.find(
237+ (job) => job.status === 'completed' && job.conclusion !== 'success'
238+ );
239+
240+ if (failedJob) {
241+ core.setFailed(`Blocking: ${failedJob.name}=${failedJob.conclusion}`);
242+ return;
243+ }
244+
245+ const allCompleted = jobs.every((job) => job.status === 'completed');
246+ if (allCompleted) {
247+ core.info('Passing: all integration-github-runner matrix jobs=success');
248+ return;
249+ }
250+ }
251+ }
252+
253+ // Safety valve: fail closed if we waited too long.
254+ // This prevents a permanently pending required check.
255+ if (Date.now() - start > timeoutMs) {
256+ core.setFailed(`Timed out waiting for required integration path: ${targetLabel}`);
257+ return;
258+ }
259+
260+ // Sleep before next poll.
261+ await new Promise((resolve) => setTimeout(resolve, intervalMs));
262+ }
0 commit comments