ci: Support pull request dependencies via Depends-On.

Allow pull requests targeting master to declare same- and
cross-repository dependencies. Parse declarations with a tested Python
helper, apply exact dependency commits before the existing build matrix,
and rerun heavy CI only when an edited description changes the dependency
state.

Keep fork builds read-only and use a trusted workflow_run to validate
artifacts and post per-build dependency results. Keep the apps workflow
consistent with the implementation already merged in apache/nuttx.

Assisted-by: Kiro:gpt-5.6-sol
Signed-off-by: zhangning21 <zhangning21@xiaomi.com>
This commit is contained in:
zhangning21 2026-08-02 19:26:09 +08:00 committed by GUIDINGLI
parent f2e9924b6a
commit 2aebae7400
4 changed files with 912 additions and 1 deletions

View file

@ -14,6 +14,7 @@ name: Build
on:
pull_request:
types: [opened, synchronize, reopened, edited]
push:
branches:
- 'releases/*'
@ -24,17 +25,104 @@ permissions:
concurrency:
group: build-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
# Edited runs do not request cancellation of an active code build.
# GitHub may still replace an older pending run in this concurrency group.
cancel-in-progress: ${{ github.event.action != 'edited' }}
jobs:
# Gate heavy CI on dependency-changing edits.
Changes:
runs-on: ubuntu-latest
outputs:
should_build: ${{ steps.gate.outputs.should_build }}
steps:
# Do not let PR code control its own edit gate.
- name: Checkout base-branch CI scripts
if: ${{ github.event_name == 'pull_request' && github.event.action == 'edited' }}
uses: actions/checkout@v7
with:
ref: ${{ github.event.pull_request.base.sha }}
sparse-checkout: .github/scripts
sparse-checkout-cone-mode: false
fetch-depth: 1
path: base-ci
continue-on-error: true
- name: Checkout PR CI scripts (fallback)
if: ${{ github.event_name == 'pull_request' && github.event.action == 'edited' }}
uses: actions/checkout@v7
with:
sparse-checkout: .github/scripts
sparse-checkout-cone-mode: false
fetch-depth: 1
path: pr-ci
- name: Decide whether to run CI
id: gate
shell: bash
env:
ACTION: ${{ github.event.action }}
NEW_BODY: ${{ github.event.pull_request.body }}
OLD_BODY: ${{ github.event.changes.body.from }}
BODY_CHANGE: ${{ toJSON(github.event.changes.body) }}
BASE_CHANGE: ${{ toJSON(github.event.changes.base) }}
run: |
set -euo pipefail
if [ "${ACTION:-}" != "edited" ]; then
echo "Event '${ACTION:-push}': running CI."
echo "should_build=true" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ "$BASE_CHANGE" != "null" ]; then
echo "::notice::PR base branch changed; running CI."
echo "should_build=true" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ "$BODY_CHANGE" = "null" ]; then
echo "::notice::PR edited but body unchanged; no code/dependency change, skipping CI."
echo "should_build=false" >> "$GITHUB_OUTPUT"
exit 0
fi
PARSER="pr-ci/.github/scripts/depends_on.py"
if [ -f "base-ci/.github/scripts/depends_on.py" ]; then
PARSER="base-ci/.github/scripts/depends_on.py"
echo "Using base-branch parser for the gate."
else
echo "::notice::Base branch has no depends_on.py yet; using PR parser for the gate (bootstrap)."
fi
# Include status so invalid declarations also retrigger reporting.
NEW_STATE="$(PR_BODY="$NEW_BODY" python3 "$PARSER" --print-state)"
OLD_STATE="$(PR_BODY="$OLD_BODY" python3 "$PARSER" --print-state)"
if [ "$NEW_STATE" != "$OLD_STATE" ]; then
echo "depends-on state changed; running CI."
echo "should_build=true" >> "$GITHUB_OUTPUT"
else
echo "::notice::No depends-on change on this edit; no code change, skipping CI."
echo "should_build=false" >> "$GITHUB_OUTPUT"
fi
# Fetch the source from nuttx and nuttx-apps repos
Fetch-Source:
needs: Changes
if: ${{ needs.Changes.outputs.should_build == 'true' }}
runs-on: ubuntu-latest
steps:
- name: Checkout CI scripts
uses: actions/checkout@v7
with:
sparse-checkout: .github/scripts
sparse-checkout-cone-mode: false
fetch-depth: 1
- name: Determine Target Branches
id: gittargets
shell: bash
env:
PR_BODY: ${{ github.event.pull_request.body }}
PR_NUMBER: ${{ github.event.pull_request.number }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
REPORT_PATH: depends-on-report/result.json
run: |
OS_REF=""
APPS_REF=""
@ -81,6 +169,11 @@ jobs:
esac
fi
# Release and backport PRs ignore dependencies.
if [ -n "$PR_BODY" ] && [ "$GITHUB_BASE_REF" = "master" ]; then
python3 .github/scripts/depends_on.py --github-output
fi
echo "os_ref=$OS_REF" >> $GITHUB_OUTPUT
echo "apps_ref=$APPS_REF" >> $GITHUB_OUTPUT
@ -102,6 +195,126 @@ jobs:
path: sources/apps
fetch-depth: 1
- name: Apply depends-on PRs
if: ${{ steps.gittargets.outputs.depends_on != '' }}
shell: bash
run: |
set -uo pipefail
git config --global user.email "actions@github.com"
git config --global user.name "github-actions"
# Pass only fixed error codes to the trusted comment workflow.
mark_failed() {
echo "::error::could not apply $1 ($2)"
python3 - "$2" <<'PY'
import json, sys
p = "depends-on-report/result.json"
try:
d = json.load(open(p))
except Exception:
d = {"version": 1, "pr_number": None, "head_sha": None, "dependencies": [], "warnings": []}
d["status"] = "failed"
d["error_code"] = sys.argv[1]
open(p, "w").write(json.dumps(d))
PY
}
# Parse before the loop so process substitution cannot hide errors.
if ! python3 - > depends-on-report/deps.tsv <<'PY'
import json
with open("depends-on-report/result.json", encoding="utf-8") as f:
d = json.load(f)
for x in d["dependencies"]:
print("%s\t%d" % (x["repo"], x["number"]))
PY
then
echo "::error::could not read the dependency report"
mark_failed "depends-on" "report_parse_failed"; exit 1
fi
: > depends-on-report/applied.tsv
while IFS=$'\t' read -r DEP_REPO DEP_PR_NUM; do
[ -n "$DEP_REPO" ] || continue
DEP="${DEP_REPO}/pull/${DEP_PR_NUM}"
case "$DEP_REPO" in
"apache/nuttx") REPO_PATH="sources/nuttx" ;;
"apache/nuttx-apps") REPO_PATH="sources/apps" ;;
*)
echo "::error::Unsupported dependency repo: $DEP_REPO"
mark_failed "$DEP" "unsupported_repo"; exit 1 ;;
esac
echo "Applying dependency ${DEP}"
if [ -f "$REPO_PATH/.git/shallow" ]; then
git -C "$REPO_PATH" fetch --unshallow origin || true
fi
if ! git -C "$REPO_PATH" fetch origin "pull/${DEP_PR_NUM}/head:dep-${DEP_PR_NUM}"; then
echo "::error::Could not fetch ${DEP} (the PR may not exist)."
mark_failed "$DEP" "fetch_failed"; exit 1
fi
DEP_SHA=$(git -C "$REPO_PATH" rev-parse "dep-${DEP_PR_NUM}")
printf '%s\t%s\t%s\n' "$DEP_REPO" "$DEP_PR_NUM" "$DEP_SHA" >> depends-on-report/applied.tsv
# Stop on unrelated histories; HEAD..dep would otherwise include
# every dependency commit and could cherry-pick unrelated changes.
COMMON_BASE=$(git -C "$REPO_PATH" merge-base "dep-${DEP_PR_NUM}" HEAD || true)
if [ -z "$COMMON_BASE" ]; then
echo "::error::Could not find common base for ${DEP}"
mark_failed "$DEP" "no_common_base"; exit 1
fi
COMMITS=$(git -C "$REPO_PATH" rev-list --reverse "HEAD..dep-${DEP_PR_NUM}") || {
echo "::error::Could not list commits for ${DEP}"
mark_failed "$DEP" "rev_list_failed"; exit 1
}
if [ -z "$COMMITS" ]; then
echo "Dependency ${DEP} is already included"
continue
fi
# shellcheck disable=SC2086
if ! git -C "$REPO_PATH" cherry-pick $COMMITS; then
echo "::error::cherry-pick failed for ${DEP}."
echo "::error::If your PR contains merge commits, please rebase instead of merge."
git -C "$REPO_PATH" cherry-pick --abort || true
mark_failed "$DEP" "cherry_pick_conflict"; exit 1
fi
done < depends-on-report/deps.tsv
python3 - <<'PY'
import json
shas = {}
try:
with open("depends-on-report/applied.tsv", encoding="utf-8") as f:
for line in f:
p = line.rstrip("\n").split("\t")
if len(p) == 3:
shas[(p[0], p[1])] = p[2]
except FileNotFoundError:
pass
with open("depends-on-report/result.json", encoding="utf-8") as f:
d = json.load(f)
for dep in d.get("dependencies", []):
key = (dep.get("repo"), str(dep.get("number")))
if key in shas:
dep["head_sha"] = shas[key]
with open("depends-on-report/result.json", "w", encoding="utf-8") as f:
json.dump(d, f)
PY
# Apply failures rewrite the report file; the step output remains "ok".
- name: Upload depends-on report
if: ${{ always() && (steps.gittargets.outputs.status == 'ok' || steps.gittargets.outputs.status == 'invalid') }}
uses: actions/upload-artifact@v7.0.1
with:
name: depends-on-report
path: depends-on-report/
- name: Tar sources
run: tar zcf sources.tar.gz sources

208
.github/workflows/depends-on-comment.yml vendored Normal file
View file

@ -0,0 +1,208 @@
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# Posts validated dependency reports without checking out PR code.
# workflow_run isolates write permission and uses the default-branch workflow.
name: Depends-On Comment
on:
workflow_run:
workflows: ["Build"]
types: [completed]
permissions:
actions: read # download an artifact from the triggering run
pull-requests: write
# Keep this artifact allow-list in sync with build.yml.
env:
NUTTX_REPO: apache/nuttx
APPS_REPO: apache/nuttx-apps
jobs:
comment:
if: ${{ github.event.workflow_run.event == 'pull_request' }}
runs-on: ubuntu-latest
steps:
- name: Download depends-on report
id: dl
uses: actions/download-artifact@v8
with:
name: depends-on-report
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
path: report
continue-on-error: true
- name: Comment on the PR
if: ${{ steps.dl.outcome == 'success' }}
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const owner = context.repo.owner;
const repo = context.repo.repo;
const allow = [process.env.NUTTX_REPO, process.env.APPS_REPO].filter(Boolean);
// Validate the untrusted Build artifact's structure and safe
// rendering fields without executing fork code.
let report;
try {
report = JSON.parse(fs.readFileSync('report/result.json', 'utf8'));
} catch (e) {
core.info('No valid depends-on report; nothing to comment.');
return;
}
if (report.version !== 1) {
core.info('Unexpected report version; skipping.');
return;
}
const status = report.status;
if (status !== 'ok' && status !== 'invalid' && status !== 'failed') {
core.info(`Report status='${status}'; nothing to comment.`);
return;
}
// Match the Python parser's numeric and repository limits. Reject
// the whole report instead of silently dropping unsafe entries.
const isSafeNumber = (n) => Number.isSafeInteger(n) && n > 0;
const rawDeps = Array.isArray(report.dependencies) ? report.dependencies : null;
if (rawDeps === null) {
core.info('Report dependencies are not an array; skipping.');
return;
}
const deps = rawDeps.filter((d) => d && typeof d.repo === 'string'
&& allow.includes(d.repo) && isSafeNumber(d.number));
if (deps.length !== rawDeps.length) {
core.info('Report contains an invalid dependency entry; skipping.');
return;
}
const keys = deps.map((d) => `${d.repo}#${d.number}`);
if (new Set(keys).size !== keys.length) {
core.info('Report contains duplicate dependencies; skipping.');
return;
}
if (status === 'invalid' && deps.length !== 0) {
core.info('status=invalid but dependencies are present; skipping.');
return;
}
const isFullSha = (s) => typeof s === 'string' && /^[0-9a-f]{40}$/i.test(s);
if (status === 'ok' && !deps.every((d) => isFullSha(d.head_sha))) {
core.info('status=ok but a full dependency head SHA is missing; skipping.');
return;
}
// Bind the report to both the triggering run and the PR's current
// head. Stale, cancelled, or mismatched reports must not comment.
const headSha = context.payload.workflow_run.head_sha;
if (report.head_sha !== headSha) {
core.info('Report head_sha does not match workflow_run head_sha; skipping.');
return;
}
const claimed = Number(report.pr_number);
let prNum = null;
if (Number.isSafeInteger(claimed) && claimed > 0) {
try {
const { data: pr } = await github.rest.pulls.get({
owner, repo, pull_number: claimed });
if (pr.head.sha === headSha) prNum = claimed;
} catch (e) {
prNum = null;
}
}
if (prNum === null) {
core.info(
"workflow_run head_sha does not match the claimed PR's current " +
'head (stale/cancelled run, or artifact mismatch); skipping comment.');
return;
}
// Preserve one idempotent comment per Build run.
const runId = context.payload.workflow_run.id;
const marker = `<!-- nuttx-depends-on-bot run-${runId} -->`;
const runUrl = context.payload.workflow_run.html_url;
const shortSha = (s) =>
(typeof s === 'string' && /^[0-9a-f]{7,40}$/i.test(s)) ? ` @ ${s.slice(0, 10)}` : '';
let body;
if (status === 'ok') {
if (deps.length === 0) {
core.info('status=ok but no valid dependencies after validation; skipping.');
return;
}
const list = deps
.map((d) => `- https://github.com/${d.repo}/pull/${d.number}${shortSha(d.head_sha)}`)
.join('\n');
body =
`${marker}\n` +
`### 🔗 Cross-repo PR dependencies\n\n` +
`The read-only Build run reported the following dependent ` +
`PR(s) and fetched head SHA(s):\n\n` +
`${list}\n\n` +
`CI run: ${runUrl}`;
} else if (status === 'failed') {
if (deps.length === 0) {
core.info('status=failed but no valid dependencies after validation; skipping.');
return;
}
const list = deps
.map((d) => `- https://github.com/${d.repo}/pull/${d.number}${shortSha(d.head_sha)}`)
.join('\n');
// Render only fixed text selected by an allowed error code.
const REASONS = {
fetch_failed: 'the dependency PR could not be fetched (it may not exist)',
no_common_base: 'no common base with the dependency PR',
rev_list_failed: 'could not determine the dependency commits',
cherry_pick_conflict: 'cherry-pick failed (if your PR has merge commits, rebase instead)',
unsupported_repo: 'the dependency repository is not supported',
report_parse_failed: 'the dependency report could not be read',
};
const code = typeof report.error_code === 'string' ? report.error_code : '';
const reason = REASONS[code] ? `\n\nReason: ${REASONS[code]}` : '';
body =
`${marker}\n` +
`### ❌ Cross-repo dependency could not be applied\n\n` +
`The Build report says the declared dependency PR(s) could not ` +
`be applied, so CI did **not** run against the combined code:\n\n` +
`${list}${reason}\n\n` +
`CI run: ${runUrl}`;
} else {
const example = `depends-on: [${allow[0] || 'owner/repo'}/pull/<N> ` +
`${allow[1] || 'owner/repo'}/pull/<M>]`;
body =
`${marker}\n` +
`### ⚠️ \`depends-on\` could not be parsed\n\n` +
`A \`depends-on:\` line was found in the PR description, but no ` +
`valid dependency was parsed. Supported repositories: ` +
`\`${allow.join('`, `')}\`; the PR id must be numeric.\n\n` +
`Expected format:\n\n\`\`\`\n${example}\n\`\`\`\n\n` +
`CI run: ${runUrl}`;
}
// Update only this run's bot comment; otherwise create one.
const comments = await github.paginate(
github.rest.issues.listComments,
{ owner, repo, issue_number: prNum, per_page: 100 });
const existing = comments.find((c) =>
c.user && c.user.type === 'Bot' && c.body && c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner, repo, comment_id: existing.id, body });
core.info(`Updated this run's comment on PR #${prNum}.`);
} else {
await github.rest.issues.createComment({
owner, repo, issue_number: prNum, body });
core.info(`Created a new comment on PR #${prNum}.`);
}