Skip to content

ci: dispatch external CI to the external repositories (led-blinker pilot) - #297

Open
devin-ai-integration[bot] wants to merge 22 commits into
develfrom
devin/1793833143-ext-ci-led-blinker
Open

devin-ai-integration[bot] wants to merge 22 commits into
develfrom
devin/1793833143-ext-ci-led-blinker

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Related Issue(s) external CI restructure
Has Unit Tests (y/n) n
Documentation Included (y/n) n
Generative AI was used in this contribution (y/n) AI

Change Description

Dispatch an external project's own ci.yml and report the result on the F´ commit, instead of building the project inside nasa/fprime.

  • ext-ci-trigger.yml: trivial pull_request / merge_group workflow with permissions: {}, so GitHub's fork-approval gate applies before anything privileged runs.
  • ext-ci.yml: runs on workflow_run of the trigger, nightly, on release/** pushes and manual dispatch, always from the default branch (never PR code). One Check Run per external repo on the PR head SHA; dispatches the external ci.yml with {fprime_repository, fprime_ref, fprime_pr_number, dispatch_id}, polls, finalizes (closes open checks, cancels orphaned remote runs). A re-run re-dispatches every repo.
  • .github/actions/ext-ci/orchestrator.py (documented in .github/actions/ext-ci/README.md): standard-library-only Python script doing the above.
  • .github/actions/ext-ci/manifest.json: pilot with fprime-community/fprime-workshop-led-blinker only, failure_policy: notify (a failure shows as a neutral check and does not block). ext-build-led-blinker.yml stays until the new check has run in parallel for a while.

Credentials: one GitHub App ("External CI", registered under fprime-community: Actions rw, Checks rw, Contents ro, Pull requests ro, no webhook) installed on the external repos and on nasa/fprime; repository secrets EXT_CI_APP_ID + EXT_CI_APP_PRIVATE_KEY. The workflow mints two 1-hour installation tokens: one scoped to the external repos (dispatch/cancel), one to nasa/fprime (Check Runs, so they get their own "External CI" group in the PR checks list instead of being filed under another workflow, which is what GITHUB_TOKEN-created checks get). Until the nasa/fprime installation is approved the checks fall back to GITHUB_TOKEN. Dispatched runs have contents: read, no secrets, ccache off; the action-side pip-cache save is closed by nasa/fprime-actions cache-mode: read (separate PR) — see finding 1 below for what that does not cover.

Operation:

  • External ref: the orchestrator uses the external repo's pr-<N> branch when it exists, else its default branch (shown in the check title, e.g. success (… @ devel)); the external ci.yml checks itself out from that ref and builds against the F´ revision it is given.
  • Re-run: re-run the orchestrator run ("External CI", from Actions or via the check's Details → Orchestrator run). Attempt ≥ 2 re-dispatches only the repos whose check on that F´ commit is not green; a new push is always a full run. Re-running the trigger starts a fresh full orchestrator run. App-created checks have no "Re-run" button on the PR row (GitHub offers it for Actions checks only).
  • Display: own check suite per App, so the Checks tab shows <App name> / External CI / led-blinker and the merge box the App avatar + External CI / led-blinker; branch protection matches on the check name.

Rationale

Each ext-*.yml re-implements an external build on nasa/fprime runners and every external breakage is fixed in many workflows; this leaves nasa/fprime with dispatch/poll/report and one manifest entry per project.

Testing/Review Recommendations

Design, evidence and security review: https://github.com/JPL-Devin/fprime-test-ci-fprime/blob/main/docs/ext-ci-dispatch-plan.md. Review focus: ext-ci.yml holds the write permissions and the secret but checks out only the default branch; no pull_request_target. Proven end to end from the JPL-Devin fork: orchestrator run https://github.com/JPL-Devin/fprime/actions/runs/41948640203 dispatched https://github.com/fprime-community/fprime-workshop-led-blinker/actions/runs/41948655128 and closed the External CI / led-blinker check with its result. After merge: a PR touching a non-ignored path should get that check.

Review findings and resolutions (multi-agent review at 6052882)

Full summary review: #297 (review) (8 lenses, 2 routed out as not applicable; CI safety No-Go / merge readiness No-Go as posted, with the dispositions below).

Boundary verified clean by every lens that traced it: the orchestrator never runs PR code; none of the tokens (GITHUB_TOKEN, App installation tokens) is reachable from the PR; manifest and script are read from the default/release branch; only GitHub-authored workflow_run payload fields are trusted; PR resolution is filtered on base.repo; no pull_request_target; Check Runs can only be written by the orchestrator; concurrency keys are owner-qualified; finalize closes checks and cancels remote runs under cancellation; the dispatched led-blinker workflow has contents: read and no secrets. The context this review assumed: nasa/fprime requires approval for workflow runs from all outside collaborators, and the external repositories hold no secrets.

# Finding (lens, thread) Disposition
1 Security, must fix — PR builds are dispatched onto the external repo's default branch, so the untrusted F´ code gets default-branch Actions-cache write scope (pip cache, ccache); a poisoned entry is restored by the external repo's own CI and by later dispatched runs. Independent of ccache: false / cache-mode: read, which only stop the action's own saves. (#297 (comment)) Accepted for the led-blinker pilot (hosted runners, approval-gated fork runs, no secrets, example-repo tier: impact is CI integrity of that repo, recoverable by clearing caches). Mitigation before adding repos: dispatched runs neither restore nor save caches (external side), and/or a dedicated non-default branch per external repo as dispatch target. Hard precondition for hardware repos (see below).
2 Design, must fix — design-needs-human-adjudication: a new privileged mechanism (long-lived PAT in a repository secret, external dispatch). (#297 (comment)) Resolved: GitHub App with short-lived installation tokens from the start (no PAT); each installation limited to selected repositories.
3 Operational, must fix — failure_policy: notify also silences orchestrator-side faults and nightly/release runs: the job exits 0, nobody is alerted. (#297 (comment)) Planned fix: notify only softens the PR check; dispatch/poll errors and non-PR runs (nightly, release, merge queue) fail the job.
4 Operational + Correctness, promoted to must fix — a lost dispatch response marks the repo dispatch_failed while the remote run proceeds untracked (not polled, not cancelled). (#297 (comment)) Planned fix: on a dispatch error after the POST, look the run up by its dispatch_id run-name before giving up.
5 Supply chain, suggestions (3) — DISPATCH_TOKEN is job-level env (also visible to actions/checkout / upload-artifact); fork-controlled head_branch written raw to the step summary; on_pr: false bypassed by a manual run naming the repo plus pr_number. Planned in the next push: step-scope the secret, route head_branch through code(), honour on_pr on manual runs.
6 Correctness / Operational / Design / Maintainability / Documentation, below must-fix (≈14) — suggestions and could-fix items on error paths, naming, duplication, docs (CONTRIBUTING.md check description, manifest/ci.yml contract in the script header). Triaged individually on their threads; docstrings and type hints for every function of the orchestrator are in the next push, together with the readability items the maintainer selects.

Rule for external repositories with self-hosted / hardware runners

Compared with the current ext-*.yml model, a dispatched run executes in the external repository's trusted context (default branch, workflow_dispatch) instead of a fork pull_request context: GitHub no longer strips secrets or forces a read-only token, default-branch caches are writable (persistence that survives runner re-imaging), and the App's dispatch token can start runs on those runners without an approval click. Before any such repository is added to the manifest:

  • PR-head code never reaches hardware: on_pr: false for that entry (hardware tests devel nightly, release/** and the merge queue only), or at most same-repo heads via a job-level if: inputs.fprime_repository == 'nasa/fprime' in the external workflow.
  • External workflow: permissions: {} (or contents: read), no ${{ secrets.* }}, no cache restore or save on dispatched runs, ephemeral/JIT runners, SHA-pinned actions, CODEOWNERS on .github/workflows/ including nasa/fprime maintainers.
  • Orchestrator: a manifest flag for self-hosted entries that refuses fork heads even on manual runs.
  • Runners move with the workflow: hardware runners are registered on the external repo (or fprime-community org), label-restricted, no credentials on the host beyond the registration token, "require approval for fork PRs" enabled there too.
  • Second entry point: anyone with write on the external repo can workflow_dispatch its ci.yml with any fprime_ref, outside nasa/fprime's approval gate — the external workflow's own restrictions above are what bound that path, not the orchestrator.
  • Residual, in both the old and the new model: a persistent hardware runner executing approved external code is a trust-the-reviewer model; the new model additionally removes GitHub's automatic fork hardening on that run, which is why on_pr: false is the recommendation.

Future Work

Add the other fprime-community repos as their ci.yml lands; retire the matching ext-*.yml.

Contributor Checklist

AI Usage (see policy)

Devin, under maintainer direction, wrote the workflows, script and manifest and ran the prototype and reviews.

IAMAI

…rator)

Adds ext-ci-trigger.yml (no-op pull_request gate), ext-ci.yml (workflow_run orchestrator:
dispatch, poll, one Check Run per external repo, failed-only re-run, notify/block policy)
and .github/ext-ci-manifest.json onboarding led-blinker, zephyr-reference and
generic-hub-reference in the notify tier. Existing ext-*.yml workflows are untouched and
keep running alongside.
@devin-ai-integration

Copy link
Copy Markdown
Author

I'll fix CI failures and address comments from users with write access that start with 'Devin'.

  • Disable automatic comment, CI, and merge conflict monitoring

Prompt hidden (unlisted session)

@devin-ai-integration devin-ai-integration Bot left a comment •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

Newer findings are available below. Devin Review posted a newer report on this PR, in addition to the findings presented here.

Devin Review found 5 potential issues.

1 flag not posted on this PR by your GitHub settings — view it in Devin Review. (Configure)

Devin Review

Comment thread .github/scripts/ext-ci-orchestrator.mjs Resolved
Comment thread .github/scripts/ext-ci-orchestrator.mjs Resolved
Comment thread .github/scripts/ext-ci-orchestrator.mjs Resolved
Comment thread .github/scripts/ext-ci-orchestrator.mjs Resolved
Comment on lines +19 to +21
pr_number:
description: "PR to test and report on (takes precedence over fprime_ref)"
type: string

@devin-ai-integration devin-ai-integration Bot Nov 4, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟨 Manual dispatch accepts unapproved PR heads

A manual pr_number runs privileged external CI for a fork PR without the trigger workflow's approval gate. Users who can start this workflow can send unapproved fork code into external CI.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By design: workflow_dispatch is only available to users with write access, which is the same set of people who can press "Approve and run" on a fork PR, so a manual pr_number run is an explicit approval by a maintainer. The dispatched code never runs with secrets here either (contents: read, no secrets on the external side).

Same behaviour and workflows; folds in review findings: no automatic retry of the
non-idempotent dispatch call (a lost response falls back to dispatch_id correlation),
a failed check-status update is not treated as a failed dispatch, and a check whose
completion update failed is closed by the finalize step with the remote result.
…nifest source, compare 404, concurrency key)

- correlate/poll_all tolerate carried-forward entries (no remote_* keys) on a re-run
- a manual run with fprime_ref reads the manifest and ext ref from the branch the run was
  started from, not from the ref under test (any PR SHA is readable via /contents?ref=)
- a release push whose before SHA was rewritten runs everything instead of aborting
- concurrency group includes the event name so a PR branch named release/* cannot cancel the push run
Drop owner notifications (a notify repo is just a neutral check; no pull-requests/issues
write needed any more), failed-only re-run carry-forward (a re-run re-dispatches every repo),
the dispatch_id correlation fallback (the dispatch API returns the run id), manifest lookup
from the base branch and ETag polling.

@devin-ai-integration devin-ai-integration Bot left a comment •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

Newer findings are available below. Devin Review posted a newer report on this PR, in addition to the findings presented here.

Devin Review found 1 new potential issue.

1 flag not posted on this PR by your GitHub settings — view it in Devin Review. (Configure)

Devin Review

Comment on lines +442 to +443
with open(MANIFEST_PATH) as f:
manifest = json.load(f)

@devin-ai-integration devin-ai-integration Bot Nov 5, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Release PRs use the wrong manifest

For a release PR, run reads the default-branch manifest instead of the PR base's manifest. The default-branch checkout can select the wrong external repos or skip required checks.

Learn more

For a workflow_run event, the orchestrator checks out the default branch, even when the completed trigger belongs to a PR targeting a release branch. resolve_target stores that PR's base ref, but opening the local manifest discards it. The manifest controls both external repository selection and ignored paths, so a release PR can receive checks from the wrong configuration.

Example: Suppose release/2 adds a second external repository to its manifest while devel lists only led-blinker. A PR targeting release/2 gets only led-blinker checked, despite the release branch requiring both.

Recommended fix: Fetch and parse the manifest at target['base_ref'] using the trusted repository API. Preserve the existing missing-file fallback without treating other API errors as missing manifests.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Known trade-off of the pilot simplification in 6052882 (reading the manifest from the PR base branch was dropped on purpose to keep the script small). It only affects PRs that target release/** while the release branch's manifest differs from devel's; nightly/push runs on a release branch use that branch's own file. If a release branch ever needs a different repo list, the base-branch lookup comes back (~15 lines).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Design] Concur — also in scope for design fit: the cited plan (§5.1) compensates for the default-branch orchestrator by reading the manifest from the PR base branch; the pilot dropped that and neither the PR description nor the manifest records the release/** trade-off; severity from my scope: could fix.

Comment on lines +96 to +111
for i in range(5):
req = urllib.request.Request(API + url, data=data, method=method, headers=headers)
try:
with urllib.request.urlopen(req, timeout=60) as res:
status, hdrs, text = res.status, res.headers, res.read().decode()
except urllib.error.HTTPError as e:
status, hdrs, text = e.code, e.headers, e.read().decode(errors="replace")
except (urllib.error.URLError, OSError) as e:
if not idempotent or i == 4:
raise ApiError(f"{self.label}: {method} {url} -> network error {e}")
time.sleep(2 * 2**i)
continue
limited = status == 429 or (
status == 403 and (hdrs.get("x-ratelimit-remaining") == "0" or re.search("rate limit", text, re.I))
)
if (limited or (status >= 500 and idempotent)) and i < 4:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Maintainability] suggestion maint-structural-hazard: the retry bound is encoded three times (range(5), i == 4, i < 4).

The last-attempt test is what keeps raise AssertionError("unreachable") at the end of the loop unreachable. Bumping range(5) alone compiles and runs, but the final network error on a retryable call then takes continue out of the loop and lands on that assert; one named bound keeps the three sites in step.

Suggested change
for i in range(5):
req = urllib.request.Request(API + url, data=data, method=method, headers=headers)
try:
with urllib.request.urlopen(req, timeout=60) as res:
status, hdrs, text = res.status, res.headers, res.read().decode()
except urllib.error.HTTPError as e:
status, hdrs, text = e.code, e.headers, e.read().decode(errors="replace")
except (urllib.error.URLError, OSError) as e:
if not idempotent or i == 4:
raise ApiError(f"{self.label}: {method} {url} -> network error {e}")
time.sleep(2 * 2**i)
continue
limited = status == 429 or (
status == 403 and (hdrs.get("x-ratelimit-remaining") == "0" or re.search("rate limit", text, re.I))
)
if (limited or (status >= 500 and idempotent)) and i < 4:
attempts = 5
for i in range(attempts):
last = i == attempts - 1
req = urllib.request.Request(API + url, data=data, method=method, headers=headers)
try:
with urllib.request.urlopen(req, timeout=60) as res:
status, hdrs, text = res.status, res.headers, res.read().decode()
except urllib.error.HTTPError as e:
status, hdrs, text = e.code, e.headers, e.read().decode(errors="replace")
except (urllib.error.URLError, OSError) as e:
if not idempotent or last:
raise ApiError(f"{self.label}: {method} {url} -> network error {e}")
time.sleep(2 * 2**i)
continue
limited = status == 429 or (
status == 403 and (hdrs.get("x-ratelimit-remaining") == "0" or re.search("rate limit", text, re.I))
)
if (limited or (status >= 500 and idempotent)) and not last:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left as is: three sites in one 30-line function; will name the bound if the client grows.

Comment on lines +413 to +416
state["repos"][entry["id"]] = {
"id": entry["id"], "repository": entry["repository"], "failure_policy": entry.get("failure_policy", "block"),
"check_run_id": check["id"], "remote_conclusion": "skipped", "conclusion": "skipped",
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Maintainability] could fix maint-code-duplication: a second, narrower shape of the per-repo state record.

dispatch_one builds the record with 13 keys; this one has 6, so downstream code has to guess which keys exist: finalize() reads s.get("remote_run_id") / s.get("ext_ref") but s["failure_policy"] / s["id"], and write_summary mixes both styles on the same record. A new consumer that indexes s["ext_ref"] or s["error"] passes on the dispatch path and raises KeyError on the skipped path. One new_repo_state(entry) helper (full key set, remote_conclusion / conclusion overridden here) gives both paths one shape and lets every reader use plain indexing.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left as is for the pilot: the maintainer preferred not to add a constructor; the skip path's narrower record is read with .get() deliberately.

rc = s["remote_conclusion"]
if rc == "success":
return "success"
if s["failure_policy"] == "notify":

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Maintainability] could fix maint-code-duplication: the failure_policy == "notify" rule is re-derived at four sites.

Here, in finalize_one (l.391), in run()'s blocking filter (l.469) and in finalize() (l.502), each with its own mapping to neutral / non-blocking; the "block" default is spelled out twice more (l.269, l.414). Adding a third policy value, or renaming this one, means finding all six by grep; one is_blocking(s) predicate (or routing l.469 and l.502 through check_conclusion) makes the manifest's policy vocabulary live in one place.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left as is for the pilot: the rule now lives at three sites (check_conclusion, the blocking filter in run, finalize), each commented; a third policy value would be the moment to centralise it.

Comment thread .github/scripts/ext-ci-orchestrator.py Outdated


def code(s):
# Remote-controlled text (job/step names, branch names) is only ever rendered inside code spans.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Maintainability] could fix maint-misleading-comment: "only ever rendered inside code spans" is broader than the code.

ext_ref (a branch name, named in this comment) is interpolated raw into the Check Run title at l.300, l.394 and l.497. Titles are plain text, so there is no rendering hazard today, but a reader taking this comment as the invariant will not look for the exemption; saying "in Markdown bodies" (or routing titles through code() too) keeps the comment true.

cc @LeStarch @thomas-bc — low-confidence finding, please confirm.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 219317e: the helper now carries a docstring stating the actual rule, and the one raw use of head_branch (summary title) goes through it.

"workflow": "ci.yml",
"default_ref": "devel",
"timeout_minutes": 60,
"failure_policy": "notify",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Documentation] could fix CONTRIBUTING.md § "Automated Checks on Reference Repositories" does not describe the new ext-ci / <id> check contributors will now see on their PRs.

(Anchored here; the finding is on CONTRIBUTING.md, which this PR does not touch.) That section still only describes the in-repo ext-*.yml builds. The new check runs the external repository's own ci.yml against the PR head, appears only after a maintainer approves the fork workflow run, and with failure_policy: notify a failure shows as a neutral (non-blocking) check. Branch selection (pr-<N>, then the base branch, then devel) is unchanged, so the existing sentence stays true; the section is incomplete rather than wrong, hence not must fix. Proposed addition after the pr-<PR_NUMBER> paragraph:

Some reference repositories are checked through their own CI instead: F´ dispatches the repository's `ci.yml` against your PR head and reports the result as a check named `ext-ci / <repository id>` (see `.github/ext-ci-manifest.json`). For pull requests from forks this check appears once a maintainer has approved the workflow run. Repositories marked `failure_policy: notify` in the manifest report failures as a neutral, non-blocking check.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred: CONTRIBUTING.md will be updated when ext-build-led-blinker.yml is retired in favour of this check, so contributors read one description, not two.

Comment thread .github/scripts/ext-ci-orchestrator.py Outdated
Comment on lines +7 to +14
For every repo in .github/ext-ci-manifest.json: create a Check Run on the F´ commit under test,
dispatch the repo's own ci.yml with that commit, poll the remote run, close the Check Run with
its result. A re-run of the orchestrator re-dispatches every repo.

Two credentials, never mixed:
GITHUB_TOKEN this repo only (checks: write) - reports results
DISPATCH_TOKEN external repos only (actions: write; contents: read too if an external repo
is private, for the branch lookup) - starts remote runs

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Documentation] suggestion The manifest schema and the contract an external ci.yml must satisfy are not documented anywhere in the repository.

This header is the only place a maintainer onboarding the next fprime-community repo can look: .github/ext-ci-manifest.json is JSON (no comments) and nothing under docs/ or CONTRIBUTING.md names upstream_repository, check_prefix, paths_ignore, default_ref, timeout_minutes, failure_policy, on_pr, the EXT_CI_DISPATCH_TOKEN secret, or the four workflow_dispatch inputs the external workflow has to accept (fprime_repository, fprime_ref, fprime_pr_number, dispatch_id). Field defaults below are taken from dispatch_one / select_repos / resolve_ext_ref / check_name.

Suggested change
For every repo in .github/ext-ci-manifest.json: create a Check Run on the F´ commit under test,
dispatch the repo's own ci.yml with that commit, poll the remote run, close the Check Run with
its result. A re-run of the orchestrator re-dispatches every repo.
Two credentials, never mixed:
GITHUB_TOKEN this repo only (checks: write) - reports results
DISPATCH_TOKEN external repos only (actions: write; contents: read too if an external repo
is private, for the branch lookup) - starts remote runs
For every repo in .github/ext-ci-manifest.json: create a Check Run on the F´ commit under test,
dispatch the repo's own ci.yml with that commit, poll the remote run, close the Check Run with
its result. A re-run of the orchestrator re-dispatches every repo.
Manifest (.github/ext-ci-manifest.json):
upstream_repository only this repo dispatches on push/schedule/workflow_run (forks: workflow_dispatch only)
check_prefix Check Run name is "<check_prefix> / <id>" (default "ext-ci")
paths_ignore fnmatch globs; a PR/push touching only these gets skipped Check Runs
repos[] id, repository (owner/name), workflow (file name under .github/workflows/),
default_ref (default "main"; tried after pr-<N> and the F´ base branch),
timeout_minutes (default 60, counted from remote start), failure_policy
("block" default, or "notify" = neutral check on failure), on_pr (default true)
External workflow contract: `workflow` must accept workflow_dispatch inputs fprime_repository,
fprime_ref, fprime_pr_number (may be empty) and dispatch_id, and check out that F´ revision itself.
Two credentials, never mixed:
GITHUB_TOKEN this repo only (checks: write) - reports results
DISPATCH_TOKEN repository secret EXT_CI_DISPATCH_TOKEN, external repos only (actions: write;
contents: read too if an external repo is private, for the branch lookup)
- starts remote runs

cc @LeStarch @thomas-bc — low-confidence finding, please confirm (the right home for this may be CONTRIBUTING.md or a docs/ page rather than the script header).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d2045d3: the script header now lists every manifest field and the contract an external ci.yml must satisfy.

Comment thread .github/workflows/ext-ci.yml Outdated
Comment on lines +45 to +61
env:
GITHUB_TOKEN: ${{ github.token }}
DISPATCH_TOKEN: ${{ secrets.EXT_CI_DISPATCH_TOKEN }}
steps:
- uses: actions/checkout@v4
- name: "Dispatch, poll and report"
env:
INPUT_REPOS: ${{ inputs.repos }}
INPUT_PR_NUMBER: ${{ inputs.pr_number }}
INPUT_FPRIME_REF: ${{ inputs.fprime_ref }}
POLL_INTERVAL_SECONDS: 60
run: python3 .github/scripts/ext-ci-orchestrator.py run
- name: "Finalize (close open checks, cancel orphaned remote runs)"
if: always()
env:
JOB_STATUS: ${{ job.status }}
run: python3 .github/scripts/ext-ci-orchestrator.py finalize

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Supply Chain] suggestion DISPATCH_TOKEN is a job-level env var, so it is also handed to the actions/checkout and actions/upload-artifact steps that never need it.

Only the two ext-ci-orchestrator.py steps use the external-repo credential. Step-scoping it keeps the secret out of the process environment of every third-party action in the job (least privilege for the one credential in this design whose leak gives actions: write on the external repos).

Suggested change
env:
GITHUB_TOKEN: ${{ github.token }}
DISPATCH_TOKEN: ${{ secrets.EXT_CI_DISPATCH_TOKEN }}
steps:
- uses: actions/checkout@v4
- name: "Dispatch, poll and report"
env:
INPUT_REPOS: ${{ inputs.repos }}
INPUT_PR_NUMBER: ${{ inputs.pr_number }}
INPUT_FPRIME_REF: ${{ inputs.fprime_ref }}
POLL_INTERVAL_SECONDS: 60
run: python3 .github/scripts/ext-ci-orchestrator.py run
- name: "Finalize (close open checks, cancel orphaned remote runs)"
if: always()
env:
JOB_STATUS: ${{ job.status }}
run: python3 .github/scripts/ext-ci-orchestrator.py finalize
env:
GITHUB_TOKEN: ${{ github.token }}
steps:
- uses: actions/checkout@v4
- name: "Dispatch, poll and report"
env:
DISPATCH_TOKEN: ${{ secrets.EXT_CI_DISPATCH_TOKEN }}
INPUT_REPOS: ${{ inputs.repos }}
INPUT_PR_NUMBER: ${{ inputs.pr_number }}
INPUT_FPRIME_REF: ${{ inputs.fprime_ref }}
POLL_INTERVAL_SECONDS: 60
run: python3 .github/scripts/ext-ci-orchestrator.py run
- name: "Finalize (close open checks, cancel orphaned remote runs)"
if: always()
env:
DISPATCH_TOKEN: ${{ secrets.EXT_CI_DISPATCH_TOKEN }}
JOB_STATUS: ${{ job.status }}
run: python3 .github/scripts/ext-ci-orchestrator.py finalize

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Security] Concur — also in scope for secret handling (general-vulnerability/secret-handling): only the two script steps need the PAT that can start, cancel, re-run and approve runs on every external repo; the step-scoped env: above is the right shape.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 219317e: DISPATCH_TOKEN is now set only on the two orchestrator steps (run, finalize); checkout and upload-artifact no longer see it.

Comment thread .github/scripts/ext-ci-orchestrator.py Outdated
link = f"[run]({s['remote_run_url']})" if s.get("remote_run_url") else (code(s["error"]) if s.get("error") else "")
rows.append(f"| {code(s['id'])} | {s['repository']} | {code(s.get('ext_ref') or '-')} | {s['failure_policy']} "
f"| {s['remote_conclusion']} | {s['conclusion']} | {link} |")
where = f" (PR #{target['pr']})" if target["pr"] else f" ({target['head_branch']})"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Supply Chain] suggestion PR-controlled head_branch is written to $GITHUB_STEP_SUMMARY outside a code span.

target['head_branch'] comes from the fork PR (workflow_run.head_branch) and here lands raw in the rendered job summary, which contradicts the code() contract at the top of the file ("remote-controlled text ... only ever rendered inside code spans"). Git refname rules limit what can be smuggled (no spaces/newlines), but autolinks and inline HTML fragments still render; wrapping it closes the gap at no cost.

Suggested change
where = f" (PR #{target['pr']})" if target["pr"] else f" ({target['head_branch']})"
where = f" (PR #{target['pr']})" if target["pr"] else f" ({code(target['head_branch'])})"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Security] Concur — also in scope for CI test-runtime policy (general-vulnerability/markdown-injection): the fork-chosen workflow_run.head_branch is the only remote string that bypasses code(), and the one-line wrap above closes it.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 219317e: head_branch goes through code() in the summary title.

Comment on lines +237 to +245
if EVENT_NAME == "workflow_dispatch" and wanted and wanted != "all":
ids = [s.strip() for s in wanted.split(",") if s.strip()]
unknown = [i for i in ids if not any(r["id"] == i for r in repos)]
if unknown:
raise RuntimeError("unknown manifest ids: " + ", ".join(unknown))
repos = [r for r in repos if r["id"] in ids]
elif target["pr"]:
repos = [r for r in repos if r.get("on_pr", True)]
return repos

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Supply Chain] suggestion on_pr: false is skipped when a manual run names repos explicitly, so a PR head (including a fork's) can be dispatched to a repo that opted out of PR heads.

on_pr is the only manifest knob that can keep untrusted F´ PR code off an external repo whose ci.yml runs on persistent self-hosted runners (apple-ci, Zephyr boards, RPi); workflow_dispatch with repos=<id> + pr_number bypasses it. Honoring the flag whenever target["pr"] is set keeps that guardrail intact on manual runs too (nightly/release runs are unaffected).

Suggested change
if EVENT_NAME == "workflow_dispatch" and wanted and wanted != "all":
ids = [s.strip() for s in wanted.split(",") if s.strip()]
unknown = [i for i in ids if not any(r["id"] == i for r in repos)]
if unknown:
raise RuntimeError("unknown manifest ids: " + ", ".join(unknown))
repos = [r for r in repos if r["id"] in ids]
elif target["pr"]:
repos = [r for r in repos if r.get("on_pr", True)]
return repos
if EVENT_NAME == "workflow_dispatch" and wanted and wanted != "all":
ids = [s.strip() for s in wanted.split(",") if s.strip()]
unknown = [i for i in ids if not any(r["id"] == i for r in repos)]
if unknown:
raise RuntimeError("unknown manifest ids: " + ", ".join(unknown))
repos = [r for r in repos if r["id"] in ids]
if target["pr"]:
# on_pr: false opts a repo out of PR heads (e.g. CI on persistent self-hosted runners); honored on manual runs too
repos = [r for r in repos if r.get("on_pr", True)]
return repos

cc @LeStarch @thomas-bc — low-confidence finding, please confirm.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Design] Concur — also in scope for design fit: on_pr: false is the manifest's only PR-origin guard, so it must be a hard rule rather than a default filter before any self-hosted-runner repo is added; pair it on the external side with if: inputs.fprime_pr_number == '' on self-hosted jobs.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Security] Concur — also in scope for CI test-runtime policy (runner-safety): on_pr additionally defaults to true at l.244 (r.get("on_pr", True)), so an entry that omits it receives fork-PR code on the next push; make it opt-in (r.get("on_pr") is True) and add a manifest runner-class field (e.g. runners: github-hosted) that the orchestrator checks before dispatching any PR head, because on persistent self-hosted hardware (apple-ci, Zephyr boards, RPi, stm32) no token scope or cache mode contains untrusted F´ code.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 219317e: the on_pr filter now applies whenever a PR is the target, manual runs included.

Comment thread .github/workflows/ext-ci.yml Outdated
timeout-minutes: 180
env:
GITHUB_TOKEN: ${{ github.token }}
DISPATCH_TOKEN: ${{ secrets.EXT_CI_DISPATCH_TOKEN }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Design] must fix Human design adjudication required. A new privileged CI mechanism (new secret, new check contract, new manifest) lands with two design-doc decisions still open.

The plan this PR cites leaves §7 decisions 1 and 2 unrecorded: (1) the dispatch credential — §5.2 recommends a dedicated GitHub App over a PAT (expiry, personal attribution, per-run minting) and this PR wires a repository-secret PAT without saying why; (2) self-hosted runners (§5.3) — the manifest has no trust-class field, so nothing but on_pr and reviewer memory keeps an apple-ci / Zephyr / Raspberry Pi repo from receiving fork-PR code once it is added. Both decide what the manifest and the external ci.yml contract must guarantee before a second repo is added and before the ext-*.yml retirements start; a design-owner sign-off (or a pointer to where these were decided) belongs in the PR description now rather than after rollout.

cc @LeStarch @thomas-bc — design needs human adjudication before deeper review.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Decision: for the pilot, EXT_CI_DISPATCH_TOKEN is a fine-grained personal access token scoped to the fprime-community/fprime-workshop-led-blinker repository only (Actions read/write; no permission on nasa/fprime). A GitHub App with short-lived installation tokens replaces it before any further repository is added to the manifest.

Comment thread .github/scripts/ext-ci-orchestrator.py Outdated
s["ext_ref"] = resolve_ext_ref(entry, target)
r = ext.post(f"/repos/{entry['repository']}/actions/workflows/{entry['workflow']}/dispatches", {
"ref": s["ext_ref"],
"inputs": {"fprime_repository": target["head_repo"], "fprime_ref": target["head_sha"],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Design] could fix External CI now tests the PR head alone; the ext-build-led-blinker.yml it replaces built the PR merge commit.

reusable-project-builder.yml overlays F´ with a default actions/checkout, which on pull_request is refs/pull/<N>/merge; here the external side checks out head_repo@head_sha, so a PR branched from an old devel passes ext-ci while its merge with devel would not (only the merge-queue path still exercises a merge). A defensible trade-off — the merge ref is absent for conflicting PRs and lags force-pushes — but the PR description should state it, since this check is meant to become required.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accepted trade-off: the external workflow checks out an exact SHA, and the PR head is what the Check Run is attached to; the merge-queue run tests the merge before it lands. Recorded in the PR description.

Comment thread .github/scripts/ext-ci-orchestrator.py Outdated
Comment on lines +468 to +469
blocking = [s["id"] for s in state["repos"].values()
if s["failure_policy"] != "notify" and (s["conclusion"] or check_conclusion(s)) not in ("success", "skipped")]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Operational] must fix ops-failure-path: notify also silences orchestrator-side faults and non-PR runs, so the job exits 0 and nobody is told.

Scenario: EXT_CI_DISPATCH_TOKEN expires (fine-grained PATs: ≤ 1 year) or the nightly led-blinker build breaks after ext-build-led-blinker.yml is retired. check_conclusion maps dispatch_failed / queue_timeout / error and every nightly failure to neutral, this filter excludes them, the "External CI" job is green and the only trace is a grey check on a devel commit nobody opens — external CI is silently off for every PR until someone notices. Smallest remedy: keep notify non-blocking for PR checks but fail the job when the fault is ours (error set) or there is no PR to spare (nightly / release/** push), so the scheduled-failure notification fires as it did for ext-*.yml. Operational judgment call on the notify semantics.

Suggested change
blocking = [s["id"] for s in state["repos"].values()
if s["failure_policy"] != "notify" and (s["conclusion"] or check_conclusion(s)) not in ("success", "skipped")]
blocking = [s["id"] for s in state["repos"].values()
if (s["failure_policy"] != "notify" or s.get("error") or not target["pr"])
and (s["conclusion"] or check_conclusion(s)) not in ("success", "skipped")]

cc @LeStarch @thomas-bc — low-confidence finding, please confirm.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 219317e: notify only softens a remote build failure; orchestrator-side faults (dispatch_failed, queue_timeout, error) fail the job for every repo. The nightly and release triggers were removed in the same push (external repos run their own nightly CI), so the non-PR case is gone.

Comment thread .github/scripts/ext-ci-orchestrator.py Outdated
Comment on lines +287 to +292
r = ext.post(f"/repos/{entry['repository']}/actions/workflows/{entry['workflow']}/dispatches", {
"ref": s["ext_ref"],
"inputs": {"fprime_repository": target["head_repo"], "fprime_ref": target["head_sha"],
"fprime_pr_number": str(target["pr"] or ""), "dispatch_id": f"{RUN_ID}-{ATTEMPT}-{entry['id']}"},
"return_run_details": True,
}, idempotent=False)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Operational] could fix ops-failure-path: a dispatch whose response is lost is reported as dispatch_failed while the remote run proceeds untracked.

Scenario: the POST times out or gets a 502 after GitHub created the run (idempotent=False correctly refuses to retry). Consequence: the check closes as dispatch_failed (neutral/failure), the external run burns up to timeout_minutes of its runner with nobody polling or cancelling it, and its real result is lost; the correlate()-by-dispatch_id fallback that closed this gap in a18ed2c was dropped in the simplification. Bounded (one orphan run, one re-run) — smallest remedy: on a network/5xx error here, list workflow_dispatch runs created since dispatched_at and adopt the one whose display_title contains dispatch_id before declaring failure.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Correctness] Concur — also in scope for correctness (correctness-resource-leak): the same orphan arises when the POST returns without workflow_run_id (l.294) or SIGINT lands during the return_run_details wait on cancellation, and since remote_conclusion = "dispatch_failed" makes both poll_all() and finalize() (l.487) skip the entry the run is never cancelled — contrary to the finalize() docstring, and the .mjs-thread reply that relies on correlate() no longer holds in 6052882; severity from my scope: must fix.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Summary] Promoted to must fix — state left inconsistent on an error path: a lost dispatch response records dispatch_failed while the remote run proceeds untracked and is never cancelled.

The summary counts this finding as must fix; the original tag above stands as posted.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accepted as is: the case needs GitHub to accept the dispatch while the HTTP response is lost; the result is a visibly failed check ("dispatch failed", never a wrong success) and one untracked remote run that finishes on its own. Re-running the orchestrator for that PR repairs it. Not worth the extra code for the pilot.

Comment on lines +342 to +347
except ApiError as e:
# A single bad poll must not take the whole fan-out down; give up on this repo only
# when the credential is gone or the run vanished.
log(f"::warning::[{s['id']}] poll: {e}")
if e.status in (401, 404):
s["remote_conclusion"], s["error"] = "error", f"poll failed: {e}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Operational] could fix ops-timing-resource: both timeouts live inside the try, so while polls fail the repo never times out.

Scenario: a sustained API outage or a PAT that lost actions: read after dispatch (403, not 401/404). Each poll raises, this branch only warns, and neither the timeout_minutes nor the 2 × timeout queue check is evaluated — the loop holds the runner for the full 180-min job timeout, after which finalize() closes the check as cancelled (red even under notify). Smallest remedy: bound consecutive poll failures (15 min at POLL_S = 60 → 15 strikes) and give up with error like the 401/404 case.

Suggested change
except ApiError as e:
# A single bad poll must not take the whole fan-out down; give up on this repo only
# when the credential is gone or the run vanished.
log(f"::warning::[{s['id']}] poll: {e}")
if e.status in (401, 404):
s["remote_conclusion"], s["error"] = "error", f"poll failed: {e}"
except ApiError as e:
# A single bad poll must not take the whole fan-out down; give up on this repo only
# when the credential is gone, the run vanished, or polling has failed for 15 min straight.
log(f"::warning::[{s['id']}] poll: {e}")
s["poll_failures"] = s.get("poll_failures", 0) + 1
if e.status in (401, 404) or s["poll_failures"] * POLL_S >= 15 * 60:
s["remote_conclusion"], s["error"] = "error", f"poll failed: {e}"
else:
s["poll_failures"] = 0

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Correctness] Concur — also in scope for correctness (correctness-nontermination): the per-repo timeout_minutes contract is simply unenforced on the error path, and bounding it on the clock already kept (now() - parse_ts(s["dispatched_at"]) > 3 * timedelta(minutes=s["timeout_minutes"])) needs no new state; severity from my scope: suggestion.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left as is for the pilot: a sustained poll outage is bounded by the job's timeout-minutes, after which finalize closes the check (neutral for notify repos since d2045d3). Will add a poll-failure budget if it ever happens.

Comment thread .github/scripts/ext-ci-orchestrator.py Outdated
conclusion, title = check_conclusion(s), f"{s['remote_conclusion']} ({s['repository']} @ {s.get('ext_ref') or '-'})"
why = "Closed by the finalize step after the check update failed"
elif cancelled:
conclusion, title, why = "cancelled", "Cancelled by orchestrator", "The orchestrator run was cancelled"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Operational] could fix ops-failure-path: the cancellation path ignores failure_policy, so a notify repo can still block a PR.

Scenario: the remote run queues for up to 2 × timeout_minutes (120 min) and then runs for up to 60 min — exactly the job's timeout-minutes: 180, with checkout, dispatch and the 60-s poll granularity on top — so GitHub kills the job a minute or two before poll_all would have said timed_out, and this branch closes the check as cancelled, which branch protection counts as a failure. Rare but legal; smallest remedy: honor notify here (and/or give the job timeout margin over 3 × timeout_minutes).

Suggested change
conclusion, title, why = "cancelled", "Cancelled by orchestrator", "The orchestrator run was cancelled"
conclusion = "neutral" if s["failure_policy"] == "notify" else "cancelled"
title, why = "Cancelled by orchestrator", "The orchestrator run was cancelled"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d2045d3: a cancelled notify repo is closed as neutral.

Comment thread .github/scripts/ext-ci-orchestrator.py Outdated
Comment on lines +248 to +253
def resolve_ext_ref(entry, target):
"""pr-<N> branch on the ext repo, else the F´ base branch name, else the manifest default."""
for c in (f"pr-{target['pr']}" if target["pr"] else None, target["base_ref"]):
if c and ext.exists(f"/repos/{entry['repository']}/branches/{q(c)}"):
return c
return entry.get("default_ref", "main")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Security] must fix PR builds dispatched onto the external repo's default branch give untrusted F´ code default-branch Actions-cache write scope (ci-test-runtime-policy-violation/cache-tampering).

Fork-PR runs land on devel unless a pr-N branch exists, so the dispatched job's GITHUB_REF is led-blinker's default branch and whatever it saves to the Actions cache is restored by every later run there.
ccache: false / cache-mode: read (fprime-actions#22) only stop setup's own saves; code the PR controls (its CMake, lib/fprime/requirements.txt installs) runs as the same uid as Runner.Worker, can take ACTIONS_RUNTIME_TOKEN from it and save any key in that scope.
Later dispatched runs still restore pip and led-blinker's own devel runs restore ccache+pip, so one malicious F´ PR persists into led-blinker CI and can shape ext-ci verdicts of unrelated PRs; today's in-repo pull_request builds are cache-scoped to their PR, so this widens the exposure into another org's repo.
Fix: dispatch PR runs to a dedicated non-default ref per repo (manifest pr_ref, e.g. ext-ci; best-effort below) and require dispatched external workflows to neither save nor restore the Actions cache; self-hosted-runner repos need more (see the on_pr thread).

Suggested change
def resolve_ext_ref(entry, target):
"""pr-<N> branch on the ext repo, else the F´ base branch name, else the manifest default."""
for c in (f"pr-{target['pr']}" if target["pr"] else None, target["base_ref"]):
if c and ext.exists(f"/repos/{entry['repository']}/branches/{q(c)}"):
return c
return entry.get("default_ref", "main")
def resolve_ext_ref(entry, target):
"""pr-<N> branch on the ext repo, else the manifest's sandbox ref for PR runs, else the F´ base branch name, else the manifest default."""
candidates = [f"pr-{target['pr']}" if target["pr"] else None, entry.get("pr_ref") if target["pr"] else None, target["base_ref"]]
for c in candidates:
if c and ext.exists(f"/repos/{entry['repository']}/branches/{q(c)}"):
return c
return entry.get("default_ref", "main")

(best-effort fix; verify before applying)

cc @LeStarch @thomas-bc @bitWarrior — low-confidence finding, please confirm.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accepted for the led-blinker pilot, as a maintainer decision rather than a fix: the dispatched runs go to GitHub-hosted runners of an example repository that holds no secrets and grants contents: read only; nasa/fprime requires maintainer approval for workflow runs from all outside collaborators, so every fork head that gets dispatched has been looked at; and the worst realistic outcome is corrupted CI results or artifacts in that repository, recoverable by clearing its caches. Before a second repository is added to the manifest: dispatched runs will neither restore nor save Actions caches (change on the external side), and any repository with self-hosted or hardware runners follows the rule recorded in the PR description (no PR-head code on hardware, permissions: {}, no secrets, no caches, ephemeral runners).

Comment thread .github/workflows/ext-ci-trigger.yml Outdated
Comment on lines +3 to +5
# Deliberately trivial. This workflow runs under the repository's normal `pull_request`
# rules (including "require approval for workflows from forks"), and its completion is what
# starts the privileged orchestrator in ext-ci.yml via `workflow_run`. It must never need

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Security] could fix The design leans on "require approval for workflows from forks", but GitHub's default setting gates first-time contributors only (general-vulnerability/trust-boundary).

With the default, any returning contributor's push is dispatched into the external repos with no human in the loop — the same exposure as today's pull_request ext-*.yml, now exercised in another org's repo. State the setting this design assumes ("Require approval for all outside collaborators") here and in the PR, or document that the gate is first-time-only and size the external-side hardening for that.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d2045d3: the trigger workflow's header now states the assumed setting ("Require approval for all outside collaborators", which nasa/fprime has enabled); the PR description records it as well.

Comment thread .github/scripts/ext-ci-orchestrator.py Outdated
if unknown:
raise RuntimeError("unknown manifest ids: " + ", ".join(unknown))
repos = [r for r in repos if r["id"] in ids]
elif target["pr"]:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Correctness] suggestion Merge-queue runs bypass both the on_pr filter and the paths_ignore skip (correctness-unhandled-case).

(Same lines as the thread above, different issue: merge groups, not manual runs.) Trigger: workflow_run.event == "merge_group" leaves target["pr"] as None (l.164-168), so this branch is skipped and every manifest repo, on_pr: false ones included, is dispatched; changed_files() also returns None there (l.208-209), so docs-only merge groups are built instead of skipped. Consequence: a nightly-only repo with failure_policy: block gates the queue with a check the PR never had. No such entry exists yet and "the queue runs everything" may be intended — if so, say it in the manifest; otherwise treat merge groups like PRs.

Suggested change
elif target["pr"]:
elif target["pr"] or (EVENT_NAME == "workflow_run" and EVENT["workflow_run"]["event"] == "merge_group"):

cc @LeStarch @thomas-bc — low-confidence finding, please confirm.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentional for merge groups: a merge group is reviewed code about to land, so every repo runs there, on_pr: false ones included (that is what on_pr means: not on PR heads), and the file list is unknown so nothing is skipped. Noted in the script header.

@thomas-bc-autowot thomas-bc-autowot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review summary (run 1)

Per-agent results

Findings open at the reviewed head, by tag; everything ever raised is under Since last run.

Agent must fix suggestion could fix future work outstanding Verdict
Security Vulnerabilities 1 3 1 0 5 No-Go
Supply Chain / Runner Safety 0 3 0 0 3 Go
F Prime C/C++ Design — — — — — skipped — no touched surface
Documentation Currency 0 1 1 0 2 Go
Design 1 1 2 0 4 No-Go
Architecture — — — — — skipped — no touched surface
Test Quality 0 0 0 0 0 Go
Correctness 1 2 0 0 3 No-Go
Operational 2 0 2 0 4 No-Go
Maintainability 0 1 3 0 4 Go
CI safety — — — — — No-Go — security has 1 must-fix in category-8: fork-PR builds dispatched to the external repo's default branch get default-branch Actions-cache write scope (cache poisoning of later ext-ci verdicts and of led-blinker CI)
Totals 4 7 8 0 19 No-Go

Totals count each open thread once at its strictest tag; threads shared by concurrence appear in every sharing lens's row. Operational's second must-fix is the §14 promotion below.

Supply-chain surfaces
Surface Outstanding
Dependencies clean
Vendored / submodule clean
Build / test infrastructure clean
Workflows / actions / scripts 3 suggestion — DISPATCH_TOKEN exposed job-wide in ext-ci.yml (step-scope it)
Generator output clean
Prompt-injection clean
Review-system integrity clean
Outstanding must-fix items (4)

Security Vulnerabilities

  • PR builds dispatched onto the external repo's default branch give untrusted F´ code default-branch Actions-cache write scope (cache-tampering; low-confidence, maintainer ping attached) — #297 (comment)

Design

  • Human design adjudication required. New privileged CI mechanism (secret, check contract, manifest) lands with two plan decisions unrecorded: dispatch credential (PAT vs GitHub App) and self-hosted-runner trust class — #297 (comment)

Operational

  • notify also silences orchestrator-side faults and non-PR runs (expired EXT_CI_DISPATCH_TOKEN, nightly breakage), so the job exits 0 and nobody is told (low-confidence, maintainer ping attached) — #297 (comment)
  • A dispatch whose response is lost is recorded as dispatch_failed while the remote run proceeds untracked and is never cancelled (promoted, see below) — also: Correctness — #297 (comment)
Severity reconciliation (1 promoted)
Finding Reviewer tag Summary tag Consequence Link
Lost dispatch response → dispatch_failed while the remote run proceeds untracked, never cancelled could fix (Operational; Correctness concurred at must fix) must fix state left inconsistent on an error path: local state says failed, the live remote run is skipped by poll_all() / finalize() #297 (comment)
Retry bound encoded three times (range(5), i == 4, i < 4) suggestion suggestion (not promoted) fragile-edit hazard; no §14 row #297 (comment)
Second, narrower shape of the per-repo state record could fix could fix (not promoted) latent KeyError for a future consumer; no §14 row #297 (comment)
failure_policy == "notify" rule re-derived at four sites could fix could fix (not promoted) duplication; no §14 row #297 (comment)
"only ever rendered inside code spans" comment broader than the code could fix could fix (not promoted) in-file code comment, not an sdd / user-manual / Doxygen claim; rationale states no rendering hazard today #297 (comment)
CONTRIBUTING.md does not describe the new ext-ci / <id> check could fix could fix (not promoted) rationale states the existing sentence stays true (incomplete, not false); a CI check is not a ground-facing surface #297 (comment)
Manifest schema and external ci.yml contract undocumented suggestion suggestion (not promoted) missing documentation for a non-ground-facing surface; no §14 row #297 (comment)
DISPATCH_TOKEN job-level env reaches checkout / upload-artifact suggestion suggestion (not promoted) least-privilege hardening; no §14 row #297 (comment)
PR-controlled head_branch written to $GITHUB_STEP_SUMMARY outside a code span suggestion suggestion (not promoted) markdown-injection surface; the contradicted code() contract is an in-file comment, not a §14 documentation surface #297 (comment)
on_pr: false bypassed when a manual run names repos suggestion suggestion (not promoted) guardrail gap for repos not yet in the manifest; no §14 row #297 (comment)
External CI tests the PR head alone, not the merge commit could fix could fix (not promoted) stated trade-off, no §14 row #297 (comment)
Release PRs read the default-branch manifest (Design concurrence on the Devin Review thread) could fix could fix (not promoted) undocumented trade-off; no §14 row #297 (comment)
Both timeouts inside the try, so a failing poll never times out could fix (Correctness concurred at suggestion) suggestion (not promoted) unenforced per-repo timeout, runner held to the job limit; no §14 row — strictest concurring tag used #297 (comment)
Cancellation path ignores failure_policy, so a notify repo can block could fix could fix (not promoted) rare timing case; the cancelled conclusion reflects what happened, no §14 row #297 (comment)
Fork-approval gate is first-time-contributor-only by default could fix could fix (not promoted) the assumed setting is stated in the PR description, not an sdd / user-manual / Doxygen claim #297 (comment)
Merge-queue runs bypass on_pr and paths_ignore suggestion suggestion (not promoted) consequence conditional on a manifest entry that does not exist yet ("could become") #297 (comment)

Merge readiness

Merge readiness: No-Go — 4 outstanding must-fix items across Security Vulnerabilities, Design, Operational and Correctness (one thread shared by Operational and Correctness).

Lenses skipped on this PR

  • F Prime C/C++ Design — no touched surface: the PR's changed-file list contains no C or C++ source or header file.
  • Architecture — no touched surface: the PR's changed-file list contains no .fpp model, no topology or deployment file, and no component source or header.

Four items between this dispatch line and go-for-launch; the ground segment is otherwise clean.

@devin-ai-integration

Copy link
Copy Markdown
Author

Review findings and resolutions (multi-agent review at 6052882)

Full summary review: #297 (review) (8 lenses, 2 routed out as not applicable; CI safety No-Go / merge readiness No-Go as posted, with the dispositions below).

Boundary verified clean by every lens that traced it: the orchestrator never runs PR code; neither GITHUB_TOKEN nor EXT_CI_DISPATCH_TOKEN is reachable from the PR; manifest and script are read from the default/release branch; only GitHub-authored workflow_run payload fields are trusted; PR resolution is filtered on base.repo; no pull_request_target; Check Runs can only be written by the orchestrator; concurrency keys are owner-qualified; finalize closes checks and cancels remote runs under cancellation; the dispatched led-blinker workflow has contents: read and no secrets. The context this review assumed: nasa/fprime requires approval for workflow runs from all outside collaborators, and the external repositories hold no secrets.

# Finding (lens, thread) Disposition
1 Security, must fix — PR builds are dispatched onto the external repo's default branch, so the untrusted F´ code gets default-branch Actions-cache write scope (pip cache, ccache); a poisoned entry is restored by the external repo's own CI and by later dispatched runs. Independent of ccache: false / cache-mode: read, which only stop the action's own saves. (#297 (comment)) Accepted for the led-blinker pilot (hosted runners, approval-gated fork runs, no secrets, example-repo tier: impact is CI integrity of that repo, recoverable by clearing caches). Mitigation before adding repos: dispatched runs neither restore nor save caches (external side), and/or a dedicated non-default branch per external repo as dispatch target. Hard precondition for hardware repos (see below).
2 Design, must fix — design-needs-human-adjudication: a new privileged mechanism (long-lived PAT in a repository secret, external dispatch). (#297 (comment)) Maintainer decision. Pilot: fine-grained PAT scoped to the led-blinker repository with Actions read/write only, no nasa/fprime permission. Planned: GitHub App with short-lived installation tokens before a second repo is added.
3 Operational, must fix — failure_policy: notify also silences orchestrator-side faults and nightly/release runs: the job exits 0, nobody is alerted. (#297 (comment)) Planned fix: notify only softens the PR check; dispatch/poll errors and non-PR runs (nightly, release, merge queue) fail the job.
4 Operational + Correctness, promoted to must fix — a lost dispatch response marks the repo dispatch_failed while the remote run proceeds untracked (not polled, not cancelled). (#297 (comment)) Planned fix: on a dispatch error after the POST, look the run up by its dispatch_id run-name before giving up.
5 Supply chain, suggestions (3) — DISPATCH_TOKEN is job-level env (also visible to actions/checkout / upload-artifact); fork-controlled head_branch written raw to the step summary; on_pr: false bypassed by a manual run naming the repo plus pr_number. Planned in the next push: step-scope the secret, route head_branch through code(), honour on_pr on manual runs.
6 Correctness / Operational / Design / Maintainability / Documentation, below must-fix (≈14) — suggestions and could-fix items on error paths, naming, duplication, docs (CONTRIBUTING.md check description, manifest/ci.yml contract in the script header). Triaged individually on their threads; docstrings and type hints for every function of the orchestrator are in the next push, together with the readability items the maintainer selects.

Rule for external repositories with self-hosted / hardware runners

Compared with the current ext-*.yml model, a dispatched run executes in the external repository's trusted context (default branch, workflow_dispatch) instead of a fork pull_request context: GitHub no longer strips secrets or forces a read-only token, default-branch caches are writable (persistence that survives runner re-imaging), and EXT_CI_DISPATCH_TOKEN can start runs on those runners without an approval click. Before any such repository is added to the manifest:

  • PR-head code never reaches hardware: on_pr: false for that entry (hardware tests devel nightly, release/** and the merge queue only), or at most same-repo heads via a job-level if: inputs.fprime_repository == 'nasa/fprime' in the external workflow.
  • External workflow: permissions: {} (or contents: read), no ${{ secrets.* }}, no cache restore or save on dispatched runs, ephemeral/JIT runners, SHA-pinned actions, CODEOWNERS on .github/workflows/ including nasa/fprime maintainers.
  • Orchestrator: a manifest flag for self-hosted entries that refuses fork heads even on manual runs; GitHub App instead of the PAT.

@devin-ai-integration devin-ai-integration Bot left a comment •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

Newer findings are available below. Devin Review posted a newer report on this PR, in addition to the findings presented here.

Devin Review found 1 new potential issue.

2 flags not posted on this PR by your GitHub settings — view them in Devin Review. (Configure)

Devin Review

Comment on lines +517 to +518
if ATTEMPT > 1: # re-run: only the repos whose check is not green yet
repos = [e for e in repos if not check_succeeded(manifest, e, target)]

@devin-ai-integration devin-ai-integration Bot Nov 5, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Updated external branches retain stale green checks

When an external branch changes without a new F´ commit, check_succeeded skips it on rerun if its old check passed. The external changes go untested, and the old green check remains on the commit.

Learn more

A Check Run belongs to an F´ commit, but the external workflow runs from a separately selected branch. The external pr-<N> branch can be added or updated without changing the F´ commit. On rerun, resolve_ext_ref would select that new branch state, but the success lookup only examines prior Check Runs on the F´ SHA. The repository is excluded before the branch lookup, so the new external code never runs.

Example: PR #42 passes using external devel. A maintainer creates external pr-42 with a compatibility fix, then reruns the orchestrator for the unchanged F´ commit. The existing success causes pr-42 to be skipped instead of tested.

Recommended fix: Re-dispatch on explicit reruns when external branch state must be retested, or record the selected external ref and immutable external commit SHA with each successful check and reuse success only when those inputs match.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Known limit of the attempt-N shortcut, accepted for now: "Re-run" is meant for flaky or fixed external failures, and a green check is only reused for the same F´ commit. When the external side changed and must be retested (new or updated pr-N branch), start a fresh manual dispatch with repos=<id> and pr_number=N — attempt 1 of a new run always re-dispatches. Recording the external SHA alongside the check can come later if this turns out to be a common case.

devin-ai-integration[bot]

This comment was marked as resolved.

@devin-ai-integration devin-ai-integration Bot left a comment •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

Newer findings are available below. Devin Review posted a newer report on this PR, in addition to the findings presented here.

Devin Review found 1 new potential issue.

3 flags not posted on this PR by your GitHub settings — view them in Devin Review. (Configure)

Devin Review

# GitHub's own approval gate.
# The workflow file and the checked-out scripts come from the default branch, never from the PR.
workflow_run:
workflows: [ "External CI Trigger" ]

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Manual run cancels release PR checks

When a manual External CI run on devel finishes, it launches a self-triggered workflow_run despite the job's path guard. Workflow-level concurrency then cancels an active devel-to-release PR orchestration, leaving its external checks cancelled.

Learn more

Both workflow files use the name External CI. A workflow_run subscription matches the orchestrator's own completed runs as well as approval-gate runs. The job-level path condition skips the extra run's job, but concurrency applies at workflow scope first. A self-triggered run inherits its completed run's branch, so it can cancel an unrelated active run with the same workflow-run concurrency group.

Example: A maintainer starts a manual orchestration on devel while a PR from nasa/fprime:devel to release/1.0 has external checks running. The manual orchestration finishes and triggers an extra workflow run on devel. That extra run cancels the PR orchestration before its job is skipped; the PR's remote run and check can be cancelled.

Recommended fix: Give the approval-gate and orchestrator workflows distinct names, subscribe only to the approval-gate name, and keep the shared check prefix in the manifest if desired. This avoids creating self-triggered runs before the job-level path guard can skip them.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in cf2f06e (pushed just before this comment was re-anchored): the trigger workflow is now named "External CI Trigger", the orchestrator subscribes only to that name, and the path guard is gone, so the orchestrator never matches its own completion and no self-triggered run reaches the workflow-level concurrency group.

…) instead of a PAT

Check Runs created with GITHUB_TOKEN are filed into an arbitrary existing Actions
check suite on the PR head, so the PR shows them as '<other workflow> / ext-ci /
led-blinker'. Creating them with the External CI App's own installation token gives
them their own suite. The same App's fprime-community installation replaces
EXT_CI_DISPATCH_TOKEN; secrets are EXT_CI_APP_ID and EXT_CI_APP_PRIVATE_KEY. Falls
back to GITHUB_TOKEN for the checks while the App is not installed on this repo.
devin-ai-integration[bot]

This comment was marked as resolved.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants