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