From ab71b9077091892967c98c3a1274cad35856bc36 Mon Sep 17 00:00:00 2001 From: Mohamad Momeni Date: Sun, 5 Jul 2026 05:29:32 -0400 Subject: [PATCH 1/2] ci: fix downstream-basic trigger logic (#2474) Signed-off-by: Mohamad Momeni --- .github/workflows/downstream-basic.yml | 82 ++++++++++++++++---------- 1 file changed, 50 insertions(+), 32 deletions(-) diff --git a/.github/workflows/downstream-basic.yml b/.github/workflows/downstream-basic.yml index 104ea95fe5..15ba202546 100644 --- a/.github/workflows/downstream-basic.yml +++ b/.github/workflows/downstream-basic.yml @@ -13,95 +13,113 @@ jobs: - name: Trigger OQS-BoringSSL CI if: ${{ !cancelled() }} # run all steps independent of failures run: | - curl --silent \ - --write-out "\n%{response_code}\n" \ + response_code=$(curl --silent --show-error \ + --output response_body \ + --write-out "%{response_code}" \ --request POST \ --header "Accept: application/vnd.github+json" \ --header "Authorization: Bearer ${{ secrets.OQSBOT_GITHUB_ACTIONS }}" \ --header "X-GitHub-Api-Version: 2022-11-28" \ --data '{"event_type":"liboqs-upstream-trigger"}' \ - https://api.github.com/repos/open-quantum-safe/boringssl/dispatches | tee curl_out \ - && grep -q "204" curl_out + https://api.github.com/repos/open-quantum-safe/boringssl/dispatches) + cat response_body + test "$response_code" = "204" - name: Trigger OQS-OpenSSH CI if: ${{ !cancelled() }} # run all steps independent of failures run: | - curl --silent \ - --write-out "\n%{response_code}\n" \ + response_code=$(curl --silent --show-error \ + --output response_body \ + --write-out "%{response_code}" \ --request POST \ --header "Accept: application/vnd.github+json" \ --header "Authorization: Bearer ${{ secrets.OQSBOT_GITHUB_ACTIONS }}" \ --header "X-GitHub-Api-Version: 2022-11-28" \ --data '{"ref":"OQS-v9"}' \ - https://api.github.com/repos/open-quantum-safe/openssh/actions/workflows/ubuntu.yaml/dispatches | tee curl_out \ - && grep -q "204" curl_out + https://api.github.com/repos/open-quantum-safe/openssh/actions/workflows/ubuntu.yaml/dispatches) + cat response_body + test "$response_code" = "204" - name: Trigger oqs-provider CI if: ${{ !cancelled() }} # run all steps independent of failures run: | - curl --silent \ - --write-out "\n%{response_code}\n" \ + response_code=$(curl --silent --show-error \ + --output response_body \ + --write-out "%{response_code}" \ --user ${{ secrets.BUILD_TRIGGER_TOKEN }}: \ --request POST \ --header "Content-Type: application/json" \ --data '{ "branch": "main" }' \ - https://circleci.com/api/v2/project/gh/open-quantum-safe/oqs-provider/pipeline | tee curl_out \ - && grep -q "201" curl_out + https://circleci.com/api/v2/project/gh/open-quantum-safe/oqs-provider/pipeline) + cat response_body + test "$response_code" = "201" + pipeline_state=$(jq -r '.state' response_body) + test "$pipeline_state" != "errored" - name: Trigger liboqs-cpp CI if: ${{ !cancelled() }} # run all steps independent of failures run: | - curl --silent \ - --write-out "\n%{response_code}\n" \ + response_code=$(curl --silent --show-error \ + --output response_body \ + --write-out "%{response_code}" \ --request POST \ --header "Accept: application/vnd.github+json" \ --header "Authorization: Bearer ${{ secrets.OQSBOT_GITHUB_ACTIONS }}" \ --header "X-GitHub-Api-Version: 2022-11-28" \ --data '{"event_type":"liboqs-upstream-trigger"}' \ - https://api.github.com/repos/open-quantum-safe/liboqs-cpp/dispatches | tee curl_out \ - && grep -q "204" curl_out + https://api.github.com/repos/open-quantum-safe/liboqs-cpp/dispatches) + cat response_body + test "$response_code" = "204" - name: Trigger liboqs-go CI if: ${{ !cancelled() }} # run all steps independent of failures run: | - curl --silent \ - --write-out "\n%{response_code}\n" \ + response_code=$(curl --silent --show-error \ + --output response_body \ + --write-out "%{response_code}" \ --request POST \ --header "Accept: application/vnd.github+json" \ --header "Authorization: Bearer ${{ secrets.OQSBOT_GITHUB_ACTIONS }}" \ --header "X-GitHub-Api-Version: 2022-11-28" \ --data '{"event_type":"liboqs-upstream-trigger"}' \ - https://api.github.com/repos/open-quantum-safe/liboqs-go/dispatches | tee curl_out \ - && grep -q "204" curl_out + https://api.github.com/repos/open-quantum-safe/liboqs-go/dispatches) + cat response_body + test "$response_code" = "204" - name: Trigger liboqs-python CI if: ${{ !cancelled() }} # run all steps independent of failures run: | - curl --silent \ - --write-out "\n%{response_code}\n" \ + response_code=$(curl --silent --show-error \ + --output response_body \ + --write-out "%{response_code}" \ --request POST \ --header "Accept: application/vnd.github+json" \ --header "Authorization: Bearer ${{ secrets.OQSBOT_GITHUB_ACTIONS }}" \ --header "X-GitHub-Api-Version: 2022-11-28" \ --data '{"event_type":"liboqs-upstream-trigger"}' \ - https://api.github.com/repos/open-quantum-safe/liboqs-python/dispatches | tee curl_out \ - && grep -q "204" curl_out + https://api.github.com/repos/open-quantum-safe/liboqs-python/dispatches) + cat response_body + test "$response_code" = "204" - name: Trigger liboqs-java CI if: ${{ !cancelled() }} # run all steps independent of failures run: | - curl --silent \ - --write-out "\n%{response_code}\n" \ + response_code=$(curl --silent --show-error \ + --output response_body \ + --write-out "%{response_code}" \ --request POST \ --header "Accept: application/vnd.github+json" \ --header "Authorization: Bearer ${{ secrets.OQSBOT_GITHUB_ACTIONS }}" \ --header "X-GitHub-Api-Version: 2022-11-28" \ --data '{"event_type":"liboqs-upstream-trigger"}' \ - https://api.github.com/repos/open-quantum-safe/liboqs-java/dispatches | tee curl_out \ - && grep -q "204" curl_out + https://api.github.com/repos/open-quantum-safe/liboqs-java/dispatches) + cat response_body + test "$response_code" = "204" - name: Trigger liboqs-rust CI if: ${{ !cancelled() }} # run all steps independent of failures run: | - curl --silent \ - --write-out "\n%{response_code}\n" \ + response_code=$(curl --silent --show-error \ + --output response_body \ + --write-out "%{response_code}" \ --request POST \ --header "Accept: application/vnd.github+json" \ --header "Authorization: Bearer ${{ secrets.OQSBOT_GITHUB_ACTIONS }}" \ --header "X-GitHub-Api-Version: 2022-11-28" \ --data '{"event_type":"liboqs-upstream-trigger"}' \ - https://api.github.com/repos/open-quantum-safe/liboqs-rust/dispatches | tee curl_out \ - && grep -q "204" curl_out + https://api.github.com/repos/open-quantum-safe/liboqs-rust/dispatches) + cat response_body + test "$response_code" = "204" From ffd8377d980f62ac449261cb784f0df0e85513db Mon Sep 17 00:00:00 2001 From: Mohamad Momeni Date: Tue, 7 Jul 2026 07:04:19 -0400 Subject: [PATCH 2/2] ci: verify downstream CI results instead of only dispatch acceptance Signed-off-by: Mohamad Momeni --- .github/workflows/downstream-basic.yml | 164 ++++++----------- scripts/trigger_downstream_ci.py | 241 +++++++++++++++++++++++++ 2 files changed, 292 insertions(+), 113 deletions(-) create mode 100644 scripts/trigger_downstream_ci.py diff --git a/.github/workflows/downstream-basic.yml b/.github/workflows/downstream-basic.yml index 15ba202546..22350fd7be 100644 --- a/.github/workflows/downstream-basic.yml +++ b/.github/workflows/downstream-basic.yml @@ -9,117 +9,55 @@ jobs: trigger-downstream-ci: runs-on: ubuntu-latest + timeout-minutes: 50 + strategy: + # One failing downstream must not stop the checks for the others + # (replaces the old per-step `if: ${{ !cancelled() }}`). + fail-fast: false + matrix: + include: + - name: boringssl + repo: open-quantum-safe/boringssl + trigger: repository_dispatch + - name: openssh + repo: open-quantum-safe/openssh + trigger: workflow_dispatch + workflow: ubuntu.yaml + ref: OQS-v9 + - name: liboqs-cpp + repo: open-quantum-safe/liboqs-cpp + trigger: repository_dispatch + - name: liboqs-go + repo: open-quantum-safe/liboqs-go + trigger: repository_dispatch + - name: liboqs-python + repo: open-quantum-safe/liboqs-python + trigger: repository_dispatch + - name: liboqs-java + repo: open-quantum-safe/liboqs-java + trigger: repository_dispatch + - name: liboqs-rust + repo: open-quantum-safe/liboqs-rust + trigger: repository_dispatch + # oqs-provider no longer uses CircleCI, and none of its CI + # workflows currently expose a repository_dispatch or + # workflow_dispatch trigger (only release.yml listens, for + # "liboqs-release"). Re-enable this entry once such a trigger + # is added to oqs-provider: + # - name: oqs-provider + # repo: open-quantum-safe/oqs-provider + # trigger: repository_dispatch + name: ${{ matrix.name }} steps: - - name: Trigger OQS-BoringSSL CI - if: ${{ !cancelled() }} # run all steps independent of failures - run: | - response_code=$(curl --silent --show-error \ - --output response_body \ - --write-out "%{response_code}" \ - --request POST \ - --header "Accept: application/vnd.github+json" \ - --header "Authorization: Bearer ${{ secrets.OQSBOT_GITHUB_ACTIONS }}" \ - --header "X-GitHub-Api-Version: 2022-11-28" \ - --data '{"event_type":"liboqs-upstream-trigger"}' \ - https://api.github.com/repos/open-quantum-safe/boringssl/dispatches) - cat response_body - test "$response_code" = "204" - - name: Trigger OQS-OpenSSH CI - if: ${{ !cancelled() }} # run all steps independent of failures - run: | - response_code=$(curl --silent --show-error \ - --output response_body \ - --write-out "%{response_code}" \ - --request POST \ - --header "Accept: application/vnd.github+json" \ - --header "Authorization: Bearer ${{ secrets.OQSBOT_GITHUB_ACTIONS }}" \ - --header "X-GitHub-Api-Version: 2022-11-28" \ - --data '{"ref":"OQS-v9"}' \ - https://api.github.com/repos/open-quantum-safe/openssh/actions/workflows/ubuntu.yaml/dispatches) - cat response_body - test "$response_code" = "204" - - name: Trigger oqs-provider CI - if: ${{ !cancelled() }} # run all steps independent of failures - run: | - response_code=$(curl --silent --show-error \ - --output response_body \ - --write-out "%{response_code}" \ - --user ${{ secrets.BUILD_TRIGGER_TOKEN }}: \ - --request POST \ - --header "Content-Type: application/json" \ - --data '{ "branch": "main" }' \ - https://circleci.com/api/v2/project/gh/open-quantum-safe/oqs-provider/pipeline) - cat response_body - test "$response_code" = "201" - pipeline_state=$(jq -r '.state' response_body) - test "$pipeline_state" != "errored" - - name: Trigger liboqs-cpp CI - if: ${{ !cancelled() }} # run all steps independent of failures - run: | - response_code=$(curl --silent --show-error \ - --output response_body \ - --write-out "%{response_code}" \ - --request POST \ - --header "Accept: application/vnd.github+json" \ - --header "Authorization: Bearer ${{ secrets.OQSBOT_GITHUB_ACTIONS }}" \ - --header "X-GitHub-Api-Version: 2022-11-28" \ - --data '{"event_type":"liboqs-upstream-trigger"}' \ - https://api.github.com/repos/open-quantum-safe/liboqs-cpp/dispatches) - cat response_body - test "$response_code" = "204" - - name: Trigger liboqs-go CI - if: ${{ !cancelled() }} # run all steps independent of failures - run: | - response_code=$(curl --silent --show-error \ - --output response_body \ - --write-out "%{response_code}" \ - --request POST \ - --header "Accept: application/vnd.github+json" \ - --header "Authorization: Bearer ${{ secrets.OQSBOT_GITHUB_ACTIONS }}" \ - --header "X-GitHub-Api-Version: 2022-11-28" \ - --data '{"event_type":"liboqs-upstream-trigger"}' \ - https://api.github.com/repos/open-quantum-safe/liboqs-go/dispatches) - cat response_body - test "$response_code" = "204" - - name: Trigger liboqs-python CI - if: ${{ !cancelled() }} # run all steps independent of failures - run: | - response_code=$(curl --silent --show-error \ - --output response_body \ - --write-out "%{response_code}" \ - --request POST \ - --header "Accept: application/vnd.github+json" \ - --header "Authorization: Bearer ${{ secrets.OQSBOT_GITHUB_ACTIONS }}" \ - --header "X-GitHub-Api-Version: 2022-11-28" \ - --data '{"event_type":"liboqs-upstream-trigger"}' \ - https://api.github.com/repos/open-quantum-safe/liboqs-python/dispatches) - cat response_body - test "$response_code" = "204" - - name: Trigger liboqs-java CI - if: ${{ !cancelled() }} # run all steps independent of failures - run: | - response_code=$(curl --silent --show-error \ - --output response_body \ - --write-out "%{response_code}" \ - --request POST \ - --header "Accept: application/vnd.github+json" \ - --header "Authorization: Bearer ${{ secrets.OQSBOT_GITHUB_ACTIONS }}" \ - --header "X-GitHub-Api-Version: 2022-11-28" \ - --data '{"event_type":"liboqs-upstream-trigger"}' \ - https://api.github.com/repos/open-quantum-safe/liboqs-java/dispatches) - cat response_body - test "$response_code" = "204" - - name: Trigger liboqs-rust CI - if: ${{ !cancelled() }} # run all steps independent of failures - run: | - response_code=$(curl --silent --show-error \ - --output response_body \ - --write-out "%{response_code}" \ - --request POST \ - --header "Accept: application/vnd.github+json" \ - --header "Authorization: Bearer ${{ secrets.OQSBOT_GITHUB_ACTIONS }}" \ - --header "X-GitHub-Api-Version: 2022-11-28" \ - --data '{"event_type":"liboqs-upstream-trigger"}' \ - https://api.github.com/repos/open-quantum-safe/liboqs-rust/dispatches) - cat response_body - test "$response_code" = "204" + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # pin@v6.0.2 + - name: Trigger ${{ matrix.name }} CI and wait for its result + env: + OQSBOT_GITHUB_ACTIONS: ${{ secrets.OQSBOT_GITHUB_ACTIONS }} + run: | + python3 scripts/trigger_downstream_ci.py \ + --repo "${{ matrix.repo }}" \ + --trigger-type "${{ matrix.trigger }}" \ + --event-type "liboqs-upstream-trigger" \ + --workflow "${{ matrix.workflow || '' }}" \ + --ref "${{ matrix.ref || 'main' }}" \ + --timeout 2700 diff --git a/scripts/trigger_downstream_ci.py b/scripts/trigger_downstream_ci.py new file mode 100644 index 0000000000..cfa40101c8 --- /dev/null +++ b/scripts/trigger_downstream_ci.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT + +"""Trigger a downstream OQS project's CI and wait for its result. + +Sends a repository_dispatch or workflow_dispatch event through the GitHub +REST API, locates the workflow run(s) spawned by that event, and polls them +until every run reaches the terminal status "completed" or a timeout +expires. A single repository_dispatch may start several workflows (e.g. +oqs-boringssl runs both shared.yml and static.yml), so all spawned runs are +checked. + +Uses only the Python standard library. Runs in CI and locally; point +--api-base at a mock server for offline testing (see the PR's test harness). + +Exit codes: 0 = all downstream runs succeeded, 1 = dispatch failure, no run +spawned, run failure, or timeout, 2 = usage error. +""" + +import argparse +import functools +import json +import os +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +from datetime import datetime, timedelta, timezone + +TERMINAL_STATUS = "completed" +# "neutral" and "skipped" do not indicate downstream breakage; everything +# else (failure, timed_out, cancelled, startup_failure, stale, +# action_required) is treated as a failure so that no false success is +# reported (the root cause of issue #2474). +SUCCESS_CONCLUSIONS = {"success", "neutral", "skipped"} + + +@functools.lru_cache(maxsize=None) +def _opener(host): + """Never route loopback traffic (local mock-server testing) through + system or environment proxies; a proxy cannot reach the tester's + localhost. Real GitHub API traffic keeps normal proxy handling.""" + if host in ("127.0.0.1", "::1", "localhost"): + return urllib.request.build_opener(urllib.request.ProxyHandler({})) + return urllib.request.build_opener() + + +def _request(method, url, token, data=None): + headers = { + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {token}", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "oqs-downstream-trigger", + } + body = json.dumps(data).encode() if data is not None else None + req = urllib.request.Request(url, data=body, headers=headers, method=method) + opener = _opener(urllib.parse.urlparse(url).hostname or "") + try: + with opener.open(req, timeout=60) as resp: + raw = resp.read() + return resp.status, json.loads(raw) if raw else {}, dict(resp.headers) + except urllib.error.HTTPError as e: + raw = e.read() + try: + parsed = json.loads(raw) if raw else {} + except json.JSONDecodeError: + parsed = {"raw": raw.decode(errors="replace")} + return e.code, parsed, dict(e.headers) + except urllib.error.URLError as e: + print(f"::warning::network error contacting {url}: {e}") + return 0, {}, {} + + +def _check_rate_limit(headers): + remaining = headers.get("X-RateLimit-Remaining") + if remaining is not None and remaining.isdigit() and int(remaining) == 0: + print("::warning::GitHub API rate limit exhausted; resets at epoch " + f"{headers.get('X-RateLimit-Reset')}") + + +def _step_summary(line): + path = os.environ.get("GITHUB_STEP_SUMMARY") + if path: + with open(path, "a", encoding="utf-8") as fh: + fh.write(line + "\n") + + +def send_dispatch(args): + if args.trigger_type == "repository_dispatch": + url = f"{args.api_base}/repos/{args.repo}/dispatches" + payload = {"event_type": args.event_type} + else: + url = (f"{args.api_base}/repos/{args.repo}" + f"/actions/workflows/{args.workflow}/dispatches") + payload = {"ref": args.ref} + status, body, headers = _request("POST", url, args.token, payload) + _check_rate_limit(headers) + if status != 204: + print(f"::error::dispatch to {args.repo} failed: " + f"HTTP {status} -> {body}") + return False + print(f"{args.repo}: dispatch accepted (HTTP 204, {args.trigger_type})") + return True + + +def find_runs(args, since): + """Return {run_id: html_url} for runs spawned by our dispatch.""" + created = ">=" + since.strftime("%Y-%m-%dT%H:%M:%SZ") + url = (f"{args.api_base}/repos/{args.repo}/actions/runs" + f"?event={args.trigger_type}" + f"&created={urllib.parse.quote(created)}&per_page=30") + + def fetch(): + status, body, headers = _request("GET", url, args.token) + _check_rate_limit(headers) + if status != 200: + print(f"{args.repo}: run lookup got HTTP {status}") + return {} + runs = body.get("workflow_runs", []) + if args.workflow: + runs = [r for r in runs + if r.get("path", "").endswith(args.workflow)] + return {r["id"]: r["html_url"] for r in runs} + + for attempt in range(1, args.find_attempts + 1): + found = fetch() + if found: + # One dispatch can spawn several workflows whose runs appear a + # few seconds apart; re-list once after a grace period so + # siblings are not missed. + time.sleep(args.find_backoff) + found.update(fetch()) + for run_id, run_url in sorted(found.items()): + print(f"{args.repo}: found run {run_id} -> {run_url}") + return found + time.sleep(args.find_backoff * attempt) + print(f"::error::{args.repo}: dispatch was accepted but no workflow run " + f"appeared after {args.find_attempts} attempts") + return {} + + +def poll_runs(args, runs): + """Poll every run until terminal or deadline; return {run_id: conclusion}.""" + deadline = time.monotonic() + args.timeout + conclusions = {} + while len(conclusions) < len(runs) and time.monotonic() < deadline: + for run_id in runs: + if run_id in conclusions: + continue + status, body, headers = _request( + "GET", f"{args.api_base}/repos/{args.repo}/actions/runs/{run_id}", + args.token) + _check_rate_limit(headers) + if status != 200: + print(f"{args.repo}: poll of run {run_id} got HTTP {status}") + continue + run_status = body.get("status") + conclusion = body.get("conclusion") + print(f"{args.repo}: run {run_id} status={run_status} " + f"conclusion={conclusion}") + if run_status == TERMINAL_STATUS: + conclusions[run_id] = conclusion + if len(conclusions) < len(runs): + time.sleep(args.poll_interval) + return conclusions + + +def main(): + parser = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--repo", required=True, help="owner/repo") + parser.add_argument("--trigger-type", required=True, + choices=["repository_dispatch", "workflow_dispatch"]) + parser.add_argument("--event-type", default="liboqs-upstream-trigger", + help="repository_dispatch event type") + parser.add_argument("--workflow", default=None, + help="workflow file name for workflow_dispatch " + "(e.g. ubuntu.yaml)") + parser.add_argument("--ref", default="main", + help="git ref for workflow_dispatch") + parser.add_argument("--timeout", type=int, default=2700, + help="seconds to wait for the run(s) to complete") + parser.add_argument("--poll-interval", type=int, default=20) + parser.add_argument("--find-attempts", type=int, default=12, + help="attempts to locate the spawned run(s)") + parser.add_argument("--find-backoff", type=int, default=5, + help="base seconds between locate attempts " + "(multiplied by the attempt number)") + parser.add_argument("--api-base", + default=os.environ.get("GITHUB_API_BASE", + "https://api.github.com")) + parser.add_argument("--token", + default=os.environ.get("OQSBOT_GITHUB_ACTIONS")) + args = parser.parse_args() + + if not args.token: + print("::error::no token (set OQSBOT_GITHUB_ACTIONS or pass --token)") + return 2 + if args.workflow == "": + args.workflow = None + if args.trigger_type == "workflow_dispatch" and not args.workflow: + print("::error::--workflow is required for workflow_dispatch") + return 2 + + since = datetime.now(timezone.utc) - timedelta(seconds=30) + if not send_dispatch(args): + _step_summary(f"- :x: `{args.repo}`: dispatch failed") + return 1 + + runs = find_runs(args, since) + if not runs: + _step_summary(f"- :x: `{args.repo}`: dispatch accepted but no " + f"workflow run appeared") + return 1 + + conclusions = poll_runs(args, runs) + failed = [] + for run_id, run_url in sorted(runs.items()): + conclusion = conclusions.get(run_id) + if conclusion is None: + failed.append((run_id, "timeout", run_url)) + elif conclusion not in SUCCESS_CONCLUSIONS: + failed.append((run_id, conclusion, run_url)) + + if not failed: + print(f"::notice::{args.repo}: all {len(runs)} downstream run(s) " + f"succeeded") + _step_summary(f"- :white_check_mark: `{args.repo}`: " + f"{len(runs)} run(s) succeeded") + return 0 + for run_id, reason, run_url in failed: + print(f"::error::{args.repo}: run {run_id} -> {reason} ({run_url})") + _step_summary(f"- :x: `{args.repo}`: run {run_id} {reason} — " + f"{run_url}") + return 1 + + +if __name__ == "__main__": + sys.exit(main())