Skip to content

Commit d3fefdb

Browse files
koenkooiCopilot
andcommitted
ci: harden trusted test handoff
Derive PR metadata from GitHub, reject fork builds on shared runners, validate private artifact provenance, and require every rendered LAVA result. Generated-by: GitHub Copilot (GPT-5.6 Sol, gpt-5.6-sol) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 2229f54 commit d3fefdb

8 files changed

Lines changed: 175 additions & 37 deletions

File tree

.github/workflows/Readme.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ The repository runs policy checks, standalone OpenEmbedded builds and one hardwa
44

55
## Build flow
66

7-
`pr.yml` and `push.yml` call `build-openembedded.yml`. Build jobs use the `[self-hosted, qcom-u2404, amd64]` project runner pool and reuse `/efs/qli/meta-qcom/downloads` and `/efs/qli/meta-qcom/sstate-cache`. Each job has an isolated temporary build directory; only the OpenEmbedded downloads and sstate caches are shared.
7+
`pr.yml` and `push.yml` call `build-openembedded.yml`. Build jobs use the `[self-hosted, qcom-u2404, amd64]` project runner pool and reuse `/efs/qli/meta-qcom/downloads` and `/efs/qli/meta-qcom/sstate-cache`. Each job has an isolated temporary build directory; only the OpenEmbedded downloads and sstate caches are shared. Pull requests from forks are rejected because untrusted fork code must not execute on credential-bearing self-hosted runners or write to shared caches; a maintainer must reproduce the change on a repository branch.
88

99
The standalone matrix builds every recipe in this layer for `qemuarm`, `qemuarm64` and `qemux86-64` with `DISTRO = "nodistro"`. The RB3 Gen 2 job combines the checked-out meta-ai revision with meta-qcom and meta-qcom-distro, installs the AI runtime packages into `qcom-multimedia-image`, and reuses meta-qcom's compile and private artifact actions.
1010

@@ -14,7 +14,7 @@ Kas and cross-repository actions are pinned to reviewed commits. Kas lockfiles p
1414

1515
## LAVA flow
1616

17-
`test-pr.yml` runs from the default branch after `Build on PR` completes. The pull request build has no LAVA or reporting credentials; the trusted `workflow_run` downloads the original event and build URL artifacts without executing pull request code. It passes only `LAVATOKEN` to `test.yml` and only `TEST_REPORTING_APP_TOKEN` to `publish-results.yml`.
17+
`test-pr.yml` runs from the default branch after `Build on PR` completes. The pull request build has no LAVA or reporting credentials. The trusted `workflow_run` resolves pull request metadata through the GitHub API, creates its own event artifact, and accepts only a build URL matching the triggering repository, run ID and attempt. It passes only `LAVATOKEN` to `test.yml` and only `TEST_REPORTING_APP_TOKEN` to `publish-results.yml`.
1818

1919
`test.yml` reuses meta-qcom's pinned LAVA-plan and result-summary actions. It boots the RB3 Gen 2 image first, then runs the pinned meta-qcom-distro pre-merge plan. Matrix failures are recorded individually and collapsed into explicit required results, so a skipped or incomplete test cannot appear successful.
2020

.github/workflows/pr.yml

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,16 @@ permissions:
1414
contents: read
1515

1616
jobs:
17-
event-file:
18-
name: "Upload event file"
17+
fork-policy:
18+
name: Fork build policy
19+
if: github.event.pull_request.head.repo.full_name != github.repository
1920
runs-on: ubuntu-latest
2021
steps:
21-
- name: Upload
22-
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
23-
with:
24-
name: Event File
25-
path: ${{ github.event_path }}
26-
if-no-files-found: error
22+
- name: Require a repository branch
23+
run: |
24+
echo "OpenEmbedded builds do not execute fork code on Qualcomm self-hosted runners."
25+
echo "A maintainer must reproduce the change on a branch in ${GITHUB_REPOSITORY}."
26+
exit 1
2727
2828
pr-to-workflow-dependency:
2929
name: "PR to workflow dependency"
@@ -38,6 +38,7 @@ jobs:
3838
pr_number: ${{ github.event.pull_request.number }}
3939

4040
build-pr:
41+
if: github.event.pull_request.head.repo.full_name == github.repository
4142
permissions:
4243
actions: read
4344
contents: read

.github/workflows/test-openembedded-distro.yml

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ jobs:
5959
contents: read
6060
outputs:
6161
artifact_id: ${{ steps.lava-test-plans.outputs.artifact_id }}
62+
job_count: ${{ steps.require-jobs.outputs.job_count }}
6263
jobmatrix: ${{ steps.lava-test-plans.outputs.jobmatrix }}
6364
steps:
6465
- name: Render boot jobs
@@ -78,13 +79,16 @@ jobs:
7879
pr_url: ${{ inputs.pr_url }}
7980

8081
- name: Require rendered boot jobs
82+
id: require-jobs
8183
env:
8284
ARTIFACT_ID: ${{ steps.lava-test-plans.outputs.artifact_id }}
8385
JOB_MATRIX: ${{ steps.lava-test-plans.outputs.jobmatrix }}
8486
run: |
8587
test -n "$ARTIFACT_ID"
8688
test -n "$JOB_MATRIX"
87-
test "$(jq '.target | length' <<< "$JOB_MATRIX")" -gt 0
89+
JOB_COUNT=$(jq '.target | length' <<< "$JOB_MATRIX")
90+
test "$JOB_COUNT" -gt 0
91+
echo "job_count=$JOB_COUNT" >> "$GITHUB_OUTPUT"
8892
8993
submit-boot-job:
9094
name: Boot ${{ matrix.target.name }}
@@ -121,14 +125,14 @@ jobs:
121125
uses: qualcomm-linux/github-action-matrix-outputs-write@59fdd8db9c91b623ec1111b9c081435b1533f529 # v3
122126
with:
123127
matrix-step-name: submit-boot-job-${{ inputs.distro_name }}
124-
matrix-key: ${{ matrix.target.name }}-${{ inputs.distro_name }}
125-
artifact-name: submit-boot-job-${{ inputs.distro_name }}-${{ matrix.target.name }}
128+
matrix-key: ${{ matrix.target.result_file }}
129+
artifact-name: submit-boot-job-${{ inputs.distro_name }}-${{ matrix.target.result_file }}
126130
outputs: |
127131
result: ${{ steps.submit.outcome }}
128132
129133
boot-job-result:
130134
name: Require boot jobs
131-
needs: submit-boot-job
135+
needs: [prepare-boot-jobs, submit-boot-job]
132136
runs-on: ubuntu-latest
133137
permissions:
134138
contents: read
@@ -149,6 +153,7 @@ jobs:
149153
- name: Require successful boot jobs
150154
id: result
151155
env:
156+
EXPECTED_RESULTS: ${{ needs.prepare-boot-jobs.outputs.job_count }}
152157
MATRIX_RESULTS: ${{ steps.matrix.outputs.result }}
153158
run: |
154159
ci/assert-matrix-results.py
@@ -164,6 +169,7 @@ jobs:
164169
contents: read
165170
outputs:
166171
artifact_id: ${{ steps.lava-test-plans.outputs.artifact_id }}
172+
job_count: ${{ steps.require-jobs.outputs.job_count }}
167173
jobmatrix: ${{ steps.lava-test-plans.outputs.jobmatrix }}
168174
steps:
169175
- name: Render pre-merge jobs
@@ -183,13 +189,16 @@ jobs:
183189
pr_url: ${{ inputs.pr_url }}
184190

185191
- name: Require rendered pre-merge jobs
192+
id: require-jobs
186193
env:
187194
ARTIFACT_ID: ${{ steps.lava-test-plans.outputs.artifact_id }}
188195
JOB_MATRIX: ${{ steps.lava-test-plans.outputs.jobmatrix }}
189196
run: |
190197
test -n "$ARTIFACT_ID"
191198
test -n "$JOB_MATRIX"
192-
test "$(jq '.target | length' <<< "$JOB_MATRIX")" -gt 0
199+
JOB_COUNT=$(jq '.target | length' <<< "$JOB_MATRIX")
200+
test "$JOB_COUNT" -gt 0
201+
echo "job_count=$JOB_COUNT" >> "$GITHUB_OUTPUT"
193202
194203
submit-premerge-job:
195204
name: Pre-merge ${{ matrix.target.name }}
@@ -226,14 +235,14 @@ jobs:
226235
uses: qualcomm-linux/github-action-matrix-outputs-write@59fdd8db9c91b623ec1111b9c081435b1533f529 # v3
227236
with:
228237
matrix-step-name: submit-premerge-job-${{ inputs.distro_name }}
229-
matrix-key: ${{ matrix.target.name }}-${{ inputs.distro_name }}
230-
artifact-name: submit-premerge-job-${{ inputs.distro_name }}-${{ matrix.target.name }}
238+
matrix-key: ${{ matrix.target.result_file }}
239+
artifact-name: submit-premerge-job-${{ inputs.distro_name }}-${{ matrix.target.result_file }}
231240
outputs: |
232241
result: ${{ steps.submit.outcome }}
233242
234243
premerge-job-result:
235244
name: Require pre-merge jobs
236-
needs: submit-premerge-job
245+
needs: [prepare-premerge-jobs, submit-premerge-job]
237246
runs-on: ubuntu-latest
238247
permissions:
239248
contents: read
@@ -251,6 +260,7 @@ jobs:
251260

252261
- name: Require successful pre-merge jobs
253262
env:
263+
EXPECTED_RESULTS: ${{ needs.prepare-premerge-jobs.outputs.job_count }}
254264
MATRIX_RESULTS: ${{ steps.matrix.outputs.result }}
255265
run: ci/assert-matrix-results.py
256266

.github/workflows/test-pr.yml

Lines changed: 59 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@ name: Test PR build
22
run-name: "LAVA: ${{ github.event.workflow_run.display_title }}"
33

44
on:
5-
# The build executes PR code without LAVA or reporting secrets. This trusted
6-
# base-branch workflow consumes only its event and build URL artifacts.
5+
# The build executes repository-branch PR code without LAVA or reporting
6+
# secrets. This trusted base-branch workflow derives metadata from GitHub and
7+
# accepts only the triggering run's expected private artifact URL.
78
workflow_run: # zizmor: ignore[dangerous-triggers]
89
workflows: ["Build on PR"]
910
types: [completed]
@@ -32,21 +33,66 @@ jobs:
3233
with:
3334
persist-credentials: false
3435

35-
- name: Download originating event
36+
- name: Query matching pull request
37+
env:
38+
GH_TOKEN: ${{ github.token }}
39+
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
40+
HEAD_REPOSITORY: ${{ github.event.workflow_run.head_repository.full_name }}
41+
run: |
42+
HEAD_OWNER=${HEAD_REPOSITORY%%/*}
43+
gh api \
44+
--method GET \
45+
"repos/${GITHUB_REPOSITORY}/pulls" \
46+
-f state=open \
47+
-f "head=${HEAD_OWNER}:${HEAD_BRANCH}" > pulls.json
48+
49+
- name: Resolve pull request
50+
id: event
51+
env:
52+
EXPECTED_BASE_REPOSITORY: ${{ github.repository }}
53+
EXPECTED_HEAD_REF: ${{ github.event.workflow_run.head_branch }}
54+
EXPECTED_HEAD_REPOSITORY: ${{ github.event.workflow_run.head_repository.full_name }}
55+
EXPECTED_HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
56+
run: ci/resolve-pr-event.py pulls.json trusted-event/event.json
57+
58+
- name: Upload trusted event
59+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
60+
with:
61+
name: Trusted Event File
62+
path: trusted-event/event.json
63+
if-no-files-found: error
64+
65+
validate-build-artifact:
66+
name: Validate build artifact
67+
needs: determine-target
68+
runs-on: ubuntu-latest
69+
permissions:
70+
actions: read
71+
contents: read
72+
steps:
73+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
74+
with:
75+
persist-credentials: false
76+
77+
- name: Download hardware build URL
3678
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
3779
with:
38-
name: Event File
39-
path: originating-event
80+
name: build-url_rb3gen2-core-kit_qcom-distro
81+
path: validated-build
4082
run-id: ${{ github.event.workflow_run.id }}
4183
github-token: ${{ github.token }}
4284

43-
- name: Resolve pull request
44-
id: event
45-
run: ci/resolve-pr-event.py originating-event/event.json
85+
- name: Require this run's private artifact URL
86+
env:
87+
EXPECTED_URL: https://qli-prod-artifacts.qualcomm.com/qcom-prd-gh-artifacts/${{ github.repository }}/${{ github.event.workflow_run.id }}-${{ github.event.workflow_run.run_attempt }}
88+
run: |
89+
ci/validate-build-url.py \
90+
validated-build/build-url_rb3gen2-core-kit_qcom-distro \
91+
"$EXPECTED_URL"
4692
4793
test:
4894
name: Run LAVA tests
49-
needs: determine-target
95+
needs: [determine-target, validate-build-artifact]
5096
if: github.event.workflow_run.conclusion == 'success' && needs.determine-target.outputs.branch == 'main'
5197
permissions:
5298
actions: read
@@ -110,13 +156,13 @@ jobs:
110156
TEST_REPORTING_APP_TOKEN: ${{ secrets.TEST_REPORTING_APP_TOKEN }}
111157
with:
112158
commit: ${{ needs.determine-target.outputs.head_sha }}
113-
event_file: artifacts/Event File/event.json
159+
event_file: artifacts/Trusted Event File/event.json
114160
event_name: pull_request
115161
workflow_id: ${{ github.event.workflow_run.id }}
116162

117163
test-pr-successful:
118164
name: PR LAVA successful
119-
needs: [determine-target, test, comment, publish]
165+
needs: [determine-target, validate-build-artifact, test, comment, publish]
120166
if: always()
121167
runs-on: ubuntu-latest
122168
permissions:
@@ -132,9 +178,11 @@ jobs:
132178
DETERMINE_RESULT: ${{ needs.determine-target.result }}
133179
PUBLISH_RESULT: ${{ needs.publish.result }}
134180
TEST_RESULT: ${{ needs.test.result }}
181+
VALIDATE_RESULT: ${{ needs.validate-build-artifact.result }}
135182
run: |
136183
ci/assert-job-results.sh \
137184
"determine-target=${DETERMINE_RESULT}" \
185+
"validate-build-artifact=${VALIDATE_RESULT}" \
138186
"test=${TEST_RESULT}" \
139187
"comment=${COMMENT_RESULT}" \
140188
"publish=${PUBLISH_RESULT}"

ci/assert-matrix-results.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,29 @@ def main() -> int:
1919
print(f"MATRIX_RESULTS is not valid JSON: {error}", file=sys.stderr)
2020
return 1
2121

22-
if not isinstance(results, dict) or not results:
22+
if not isinstance(results, dict) or not isinstance(results.get("result"), dict):
23+
print("MATRIX_RESULTS must contain a result object.", file=sys.stderr)
24+
return 1
25+
26+
results = results["result"]
27+
if not results:
2328
print("MATRIX_RESULTS must contain at least one job result.", file=sys.stderr)
2429
return 1
2530

31+
expected_results = os.environ.get("EXPECTED_RESULTS", "")
32+
if expected_results:
33+
try:
34+
expected_count = int(expected_results)
35+
except ValueError:
36+
print("EXPECTED_RESULTS must be an integer.", file=sys.stderr)
37+
return 1
38+
if len(results) != expected_count:
39+
print(
40+
f"Expected {expected_count} matrix results, received {len(results)}.",
41+
file=sys.stderr,
42+
)
43+
return 1
44+
2645
failed = False
2746
for job, result in sorted(results.items()):
2847
if result != "success":

ci/oe-check-layer.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,5 @@ exec yocto-check-layer \
3333
meta-ai \
3434
--no-auto \
3535
--dependency "$WORK_DIR/oe-core/meta" \
36+
--dependency "$WORK_DIR/meta-openembedded/meta-oe" \
3637
--no-auto-dependency

ci/resolve-pr-event.py

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,23 +9,44 @@
99

1010

1111
def main() -> int:
12-
if len(sys.argv) != 2:
13-
print(f"Usage: {sys.argv[0]} EVENT_FILE", file=sys.stderr)
12+
if len(sys.argv) != 3:
13+
print(f"Usage: {sys.argv[0]} PULLS_FILE TRUSTED_EVENT_FILE", file=sys.stderr)
1414
return 1
1515

1616
output_path = os.environ.get("GITHUB_OUTPUT")
1717
if not output_path:
1818
print("GITHUB_OUTPUT is empty.", file=sys.stderr)
1919
return 1
2020

21-
with open(sys.argv[1], encoding="utf-8") as event_file:
22-
event = json.load(event_file)
21+
expected = {
22+
"base_repository": os.environ.get("EXPECTED_BASE_REPOSITORY", ""),
23+
"head_ref": os.environ.get("EXPECTED_HEAD_REF", ""),
24+
"head_repository": os.environ.get("EXPECTED_HEAD_REPOSITORY", ""),
25+
"head_sha": os.environ.get("EXPECTED_HEAD_SHA", ""),
26+
}
27+
if not all(expected.values()):
28+
print("Expected workflow_run metadata is incomplete.", file=sys.stderr)
29+
return 1
2330

24-
pull_request = event.get("pull_request")
25-
if not isinstance(pull_request, dict):
26-
print("The event does not contain a pull_request object.", file=sys.stderr)
31+
with open(sys.argv[1], encoding="utf-8") as pulls_file:
32+
pulls = json.load(pulls_file)
33+
34+
matching_pulls = [
35+
pull
36+
for pull in pulls
37+
if pull["base"]["repo"]["full_name"] == expected["base_repository"]
38+
and pull["head"]["ref"] == expected["head_ref"]
39+
and pull["head"]["repo"]["full_name"] == expected["head_repository"]
40+
and pull["head"]["sha"] == expected["head_sha"]
41+
]
42+
if len(matching_pulls) != 1:
43+
print(
44+
f"Expected one pull request matching the workflow run, found {len(matching_pulls)}.",
45+
file=sys.stderr,
46+
)
2747
return 1
2848

49+
pull_request = matching_pulls[0]
2950
values = {
3051
"branch": pull_request["base"]["ref"],
3152
"head_sha": pull_request["head"]["sha"],
@@ -40,6 +61,16 @@ def main() -> int:
4061
return 1
4162
output.write(f"{name}={value}\n")
4263

64+
trusted_event_path = Path(sys.argv[2])
65+
trusted_event_path.parent.mkdir(parents=True, exist_ok=True)
66+
trusted_event_path.write_text(
67+
json.dumps(
68+
{"number": pull_request["number"], "pull_request": pull_request},
69+
separators=(",", ":"),
70+
),
71+
encoding="utf-8",
72+
)
73+
4374
return 0
4475

4576

0 commit comments

Comments
 (0)