Repository navigation
fix(ci): compare the ABI against the commit under test, and give Windows CI a permissions block - #3838
fix(ci): compare the ABI against the commit under test, and give Windows CI a permissions block#3838Alexandr-Solovev wants to merge 6 commits into
Conversation
…commit `ci-win.yml` was the only workflow without a `permissions:` block, so its `push: main` runs got the repository default token for a job that only builds and tests. Set `contents: read`. The ABI check took its baseline as the newest `__release_lnx` key in main's cache scope. Any job whose GITHUB_REF is main can write into that scope, including the fork pull request jobs in `Nightly-test`, and a key that did not exist before is always the newest -- so one fork pull request could pick the baseline every later pull request is compared against. Take the newest of main's last 20 commits that has an entry instead. Cache keys are immutable, so this leaves only a race against the genuine main build for a real commit's key. A baseline more than 5 commits behind main also now warns, because abidiff then reports changes that other merged pull requests introduced and that reads as a failure of the pull request under test. Signed-off-by: Alexandr-Solovev <aleksandr.solovev@intel.com>
…ain's tip abidiff reports the difference between two trees, not what a pull request changed, so the baseline has to be an ancestor of the build under test. Main's tip is not: `checkout` builds the pull request's merge commit, which contains main only up to the pull request's base. Anything main did after that lands in the report inverted, which is the known source of spurious failures -- and anything the pull request does that a merged pull request also did lands in the report as no change at all, so a real ABI break passes unflagged and the next pull request's baseline absorbs it. Take the first parent of the merge commit as the starting point and walk its ancestors for a cached build. Fall back to main's tip only when the base has no cached build left, with a warning that names what the comparison can then miss. The selection moves to `.ci/scripts/abi_baseline_key.sh`; the workflow step is now one line. Signed-off-by: Alexandr-Solovev <aleksandr.solovev@intel.com>
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
A failed base-commit API lookup can silently select an inexact baseline instead of failing the ABI check.
Review effort: Balanced
Findings: 1
Open (2)
What changed in this PR
Hardens CI permissions and makes ABI comparisons use a cached ancestor of the tested commit.
Changes:
- Restricts Windows CI to read-only repository contents.
- Adds ancestry-aware ABI baseline selection.
- Ignores untrusted or stale cache-key ordering.
| File | Description |
|---|---|
.github/workflows/ci.yml |
Uses the baseline-selection script. |
.github/workflows/ci-win.yml |
Adds restricted token permissions. |
.ci/scripts/abi_baseline_key.sh |
Selects and reports cached ABI baselines. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| newest_cached_from() { | ||
| local behind=0 sha | ||
| for sha in $(gh api "repos/${GITHUB_REPOSITORY}/commits?sha=$1&per_page=${WINDOW}" --jq '.[].sha'); do | ||
| if grep -qxF "${KEY_PREFIX}-${sha}" <<< "${available}"; then | ||
| echo "${KEY_PREFIX}-${sha} ${behind}" | ||
| return 0 | ||
| fi | ||
| behind=$((behind + 1)) | ||
| done | ||
| return 1 | ||
| } | ||
|
|
||
| result="" | ||
| exact=yes | ||
| if [ -n "${BASE_SHA}" ]; then | ||
| result=$(newest_cached_from "${BASE_SHA}") || result="" | ||
| fi |
There was a problem hiding this comment.
Agreed, and it was worse than a miss: for sha in $(gh api ...) iterates zero times when the lookup fails, and set -e does not catch a failed command substitution in a for list. So a 502 looked exactly like "no cached ancestor" and the fallback picked main's tip -- the non-ancestor baseline this whole change exists to avoid.
Fixed in c8d0188 by taking the API calls out of the loop. ancestors_of is now assigned to a variable at the top level, so a failure is a distinct status the caller stops on:
base_commits=$(ancestors_of "${BASE_SHA}") || {
echo "::error::Could not list the commits reachable from ${BASE_SHA}. Rerun this job."
exit 1
}newest_cached_in is pure string matching now, so "no candidates" can only mean no candidates. Added three tries with a 0/5/10s backoff so a single hiccup does not turn into a red check.
The cache listing keeps carrying on past a failure, and that asymmetry is deliberate: an empty listing cannot match any key, so the job stops at the existing "No __release_lnx cache on refs/heads/main" error instead of comparing against nothing. Failing there is safe; failing in the commit lookup was not. Both directions are covered by the offline dry run -- an injected 502 on the base lookup now exits 1 where it previously printed exact: no and exited 0.
| # from main's tip, and fall back to the tip only when that base has no cached | ||
| # build left, with a warning that the comparison is no longer exact. | ||
|
|
||
| set -eo pipefail |
There was a problem hiding this comment.
Right, AGENTS.md asks for set -euo pipefail and I shipped -eo. Changed in c8d0188.
The script was already written to survive -u -- the optional inputs use ${1:-} and ${GITHUB_OUTPUT:-} -- so the only behaviour change is the one you point at: with GITHUB_REPOSITORY unset the lookup now dies with GITHUB_REPOSITORY: unbound variable at the point of use instead of requesting repos//commits?sha=.... Verified by running the script with that variable unset.
Worth noting the other three scripts under .ci/ (build.sh, env/openblas.sh, env/tbb.sh) are all on set -eo pipefail. Leaving them alone here since adding -u to them is not this PR's subject.
Shorter sentences, same reasoning. Signed-off-by: Alexandr-Solovev <aleksandr.solovev@intel.com>
…line A `gh api` failure made the candidate loop iterate zero times, which `set -e` does not catch in a `for` list. That is indistinguishable from "no cached ancestor", so a transient error quietly selected main's tip -- the baseline this check exists to avoid. Both lookups are now plain assignments that stop the job, with a few retries first. A failed cache listing still carries on, since no key can then match and the job stops at the existing error. Also switches to `set -euo pipefail` per AGENTS.md. Signed-off-by: Alexandr-Solovev <aleksandr.solovev@intel.com>
napetrov
left a comment
There was a problem hiding this comment.
The ci-win.yml permissions block looks right: no job there calls gh, uses the cache actions, or needs token scopes beyond reading contents. That closes code-scanning alert 17.
The ABI baseline change has one blocking problem: as wired up in ci.yml, the script never receives the base commit, so every run takes the fallback path. This PR's own ABI Conformance(avx2) run shows it:
##[warning]No cached main build for this pull request's base or its last 20 ancestors, so the baseline is main's tip. ...
__release_lnx-82acef1606a33d34cfc22116267d73eab4d2f579 (exact: no, 0 commits back)
That cache entry exists, and it is the merge commit's first parent, so the exact path should have matched. Details and a fix are inline. There are two smaller points too: a baseline walked back past the base can still hide a removal (reproduced with abidiff), and the "Provenance" rationale does not match GitHub's current cache rules.
Note: this PR does not touch nightly-test.yml, so code-scanning alerts 15/16 stay open after it merges.
| # `checkout` leaves the pull request's merge commit at HEAD; its first | ||
| # parent is the main commit this build contains. That commit is the | ||
| # baseline, not main's tip -- the script explains why. | ||
| .ci/scripts/abi_baseline_key.sh "$(git log -1 --format=%P | cut -d' ' -f1)" |
There was a problem hiding this comment.
actions/checkout fetches with --depth=1 (this run's log: fetch --depth=1 origin +8198e87…:refs/remotes/pull/3838/merge). In a shallow clone git treats the boundary commit as parentless, so git log -1 --format=%P prints an empty string and the script is called with "".
I reproduced this with the same fetch as the job:
$ git fetch --depth=1 origin +refs/pull/3838/merge:refs/remotes/pull/3838/merge && git checkout --detach refs/remotes/pull/3838/merge
$ git log -1 --format=%P # -> "" (what the PR passes)
$ git rev-parse HEAD^1 # -> fails, "HEAD^1"
$ git cat-file -p HEAD | awk '/^parent /{print $2; exit}' # -> 82acef1606a33d34cfc22116267d73eab4d2f579
Then I ran the script from this PR against the live cache listing with each input:
abi_baseline_key.sh "" -> __release_lnx-51b69f23… (exact: no, 0 commits back) + fallback warning
abi_baseline_key.sh 82acef16… -> __release_lnx-82acef16… (exact: yes, 0 commits back)
The commit object stores its parents even when the clone is shallow, so reading it directly works:
| .ci/scripts/abi_baseline_key.sh "$(git log -1 --format=%P | cut -d' ' -f1)" | |
| .ci/scripts/abi_baseline_key.sh "$(git cat-file -p HEAD | awk '/^parent /{print $2; exit}')" |
${{ github.event.pull_request.base.sha }} would also work: for this run it equals the merge commit's first parent (82acef16…), and re-runs reuse the same payload. cat-file has the advantage of reading the exact commit that was built.
There was a problem hiding this comment.
Fixed by your 9e66d78. I reviewed it and agree; the description is updated to match.
|
|
||
| result="" | ||
| exact=yes | ||
| if [ -n "${BASE_SHA}" ]; then |
There was a problem hiding this comment.
An empty BASE_SHA silently falls through to the "no cached main build for this pull request's base" warning. That message is wrong for this case, and it is the reason the bug above passed CI unnoticed. In pull_request runs an empty base is always a wiring bug, not a cache gap. Suggest failing fast instead:
if [ -z "${BASE_SHA}" ]; then
echo "::error::No base commit was passed. The caller must pass the first parent of the merge commit under test."
exit 1
fi(This would change the "non-merge HEAD falls back and warns" case from the validation list. If that case is still wanted, it should at least print its own message.)
There was a problem hiding this comment.
Fixed by your 9e66d78. I reviewed it and agree; the description is updated to match.
|
|
||
| if [ "${exact}" = no ]; then | ||
| echo "::warning::No cached main build for this pull request's base or its last ${WINDOW} ancestors, so the baseline is main's tip. Changes merged into main since the base will show up in this check, and a change this pull request shares with one of them will not show up at all. Rebase onto main for an exact comparison." | ||
| elif [ "${behind}" -gt "${STALE_AFTER}" ]; then |
There was a problem hiding this comment.
The description says the diff will contain "only the pull request's own changes: no spurious failures from drift, and no masking either". That holds only when the match is 0 commits back. If the base itself is not cached, the walk picks an older ancestor. The diff then also contains the main commits in between, and a pull request that undoes one of them is hidden. I reproduced this with abidiff (libabigail from Ubuntu 24.04):
- M0: exports
f - M1 = M0 + adds
S(an addition, so no label needed) - PR on M1 removes
S
abidiff libM1.so libPR.so -> "1 Removed function: int S()" rc=12 (abi_check.sh fails)
abidiff libM0.so libPR.so -> (no output) rc=0 (abi_check.sh passes)
This window comes up regularly: on current main, the __release_lnx-<sha> entry is created about 70 minutes after the commit lands (51b69f2: committed 13:25Z, cached 14:34Z; 82acef1: 16:39Z → 17:49Z). Any PR run in that window matches 1 commit back, and with STALE_AFTER=5 it gets no warning at all.
Suggestions, in order of preference:
- treat
behind > 0as::error::("baseline for is not cached yet, the main CI run is probably still in progress; re-run this job later"), or - at least default
STALE_AFTERto 0, so any inexact match is visible.
The description should also be adjusted to say that the masking guarantee only holds for an exact match.
There was a problem hiding this comment.
Fixed by your 9e66d78. I reviewed it and agree; the description is updated to match.
| # scope would miss, and the comparison would then run against an empty | ||
| # directory. | ||
| # | ||
| # Provenance. Any job whose GITHUB_REF is main can write into main's cache |
There was a problem hiding this comment.
Any job whose GITHUB_REF is main can write into main's cache scope, the fork pull request jobs in Nightly-test included
This no longer matches GitHub's documented behaviour. Runs triggered by workflow_run (and pull_request_target, issue_comment) get read-only access to the default branch's cache unless the workflow declares a write-capable cache-mode. Only push, workflow_dispatch, repository_dispatch, schedule, delete, registry_package and page_build can write to it (dependency caching → Cache access for low-trust workflow triggers). CodeQL's cache-poisoning query models the same split: it flags nightly-test.yml only for its workflow_dispatch trigger.
Deriving keys from commits is still the right design, so no code change is needed, but the comment should say what it actually defends against. Also, "keys are immutable, so the only race left is against the real main build" does not cover keys for older main commits whose entries have expired (7-day TTL) or been evicted. Whoever can write could plant one of those, and the walk would pick it if nothing newer is cached. The impact is bounded either way, since the restored tree only feeds abidiff as data.
There was a problem hiding this comment.
Fixed by your 9e66d78. I reviewed it and agree; the description is updated to match.
|
|
||
| # A failure to list the caches is safe to carry on from: no key can match, so | ||
| # the job stops at the hard error below rather than comparing against nothing. | ||
| available=$(retry gh cache list --ref refs/heads/main --key "${KEY_PREFIX}" --limit 100 --json key --jq '.[].key') || available="" |
There was a problem hiding this comment.
If gh cache list fails three times, available="" and the job ends with "No __release_lnx cache on refs/heads/main … Rerun the 'CI' workflow on main via workflow dispatch". Failing is correct, but that advice is wrong: re-running main's CI won't fix an API outage. Checked with a stubbed gh that returns HTTP 502 for cache:
HTTP 502: Bad Gateway (x3)
::error::No __release_lnx cache on refs/heads/main for any of its last 20 commits. Rerun the 'CI' workflow on main via workflow dispatch to regenerate it.
Suggest giving the listing failure its own ::error::Could not list caches … rerun this job, the same way ancestors_of failures are handled.
There was a problem hiding this comment.
Fixed by your 9e66d78. I reviewed it and agree; the description is updated to match.
|
Follow-up on code scanning, separate from the ABI review above. This pull request closes alert #17. I ran CodeQL 2.27.1 ( Alerts #15 and #16 stay open, since this pull request does not touch Why CodeQL didn't comment here: this pull request comes from a fork, and the repository's CodeQL default setup skips fork pull requests. No CodeQL analysis ran for it, which is why I checked locally. #3848 moves to advanced setup so fork pull requests get a |
`actions/checkout` makes a depth-1 clone, and git reports no parents for the boundary commit, so `git log -1 --format=%P` passed an empty base and every run took the main-tip fallback. Read the parent from the commit object instead, which keeps it even in a shallow clone. Also: - fail on an empty base instead of reporting a cache gap, since an empty base is always a wiring bug in the caller; - warn whenever the baseline is behind the base, not only past 5 commits: a walked-back baseline hides a change that undoes one of the skipped main commits, and on main the cache entry lands about 70 minutes after each commit, so this happens routinely; - report a failed cache listing as such, not as a missing main cache; - pass the repository to `gh cache list` explicitly, as the commit lookup already does; - correct the provenance note: workflow_run and pull_request_target runs only get read access to main's cache scope. Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
Alerts 15 and 16 (actions/cache-poisoning/poisonable-step) are raised for
the workflow_dispatch trigger, which still gets write access to main's
cache scope. `cache-mode: none` removes cache access from the job token;
the cache service refuses writes with such a token ("cache write denied:
token has no writable scopes"). The CVE scan keeps `read`, the access it
has today under workflow_run, for the action's pip cache.
The per-step blanking of ACTIONS_RUNTIME_TOKEN and friends is removed. On a
GitHub-hosted Linux runner, `run:` steps do not receive that token at all
(only JavaScript actions do), and blanking a variable does not take the
token away from the runner process. The query does not
model cache-mode, so the two alerts are to be dismissed with a reference to
the note at the top of the file.
Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
|
@Alexandr-Solovev I pushed two commits onto this branch (fast-forward, nothing of yours rewritten). They address my review above and close the remaining code-scanning alerts. Please check that you agree with them.
|
| Case | Result |
|---|---|
base cached (b78cd81) |
exact: yes, 0 commits back, key= written |
base's own entry not saved yet (live 500d5ac, and a stub hiding the entry) |
walk-back warning, exact: no, 1 commits back |
| empty base | ::error::No base commit was passed…, rc 1 |
gh cache list fails 3× (stubbed 502) |
::error::Could not list the caches on refs/heads/main. Rerun this job., rc 1 |
| unknown base SHA | ::error::Could not list the commits reachable from …, rc 1 |
1fee65b ci(nightly-test): take cache access away with cache-mode: none
This closes the gap behind alerts #15/#16: workflow_dispatch runs of Nightly-test still get write access to main's cache scope.
- Added
cache-mode: noneat workflow level.cve-bin-tool-scangetscache-mode: read, the access it has today underworkflow_run, so the action can still restore its pip cache. - The cache service enforces it, not just
actions/cache. I tested this in my fork with a JavaScript action that callsCacheService/CreateCacheEntrydirectly using the job's runtime token. Withcache-mode: nonethe call returns"cache write denied: token has no writable scopes"; the control job withcache-mode: writegets"ok":true. - Removed the per-step
ACTIONS_RUNTIME_TOKEN/ACTIONS_RESULTS_URL/ACTIONS_CACHE_URLblanking and the comments that require it. In the same test, arun:step on a GitHub-hosted Linux runner did not receiveACTIONS_RUNTIME_TOKENat all; only JavaScript actions get it. So the blanking never removed anything, and it cannot take the token away from the runner process. - GitHub accepts the key at both workflow and job level: dispatching this exact file on a branch in my fork created a run with all five jobs (skipped there by the
github.repositoryguard). actionlint, even 1.7.12, doesn't knowcache-modeyet, but this repository doesn't run actionlint.
CodeQL 2.27.1 locally on this branch reports 2 results, down from 3 on main. Alert 17 is gone; the two poisonable-step findings stay on the same two steps, because the query does not model cache-mode. The CodeQL check on this pull request (now that fork pull requests are scanned, #3848) reports "No new alerts in code changed by this pull request". After merge I will dismiss #15/#16 with a link to the note at the top of nightly-test.yml.
Description
Three statements in the description no longer match the code: the provenance paragraph ("fork pull request jobs in Nightly-test can write into main's cache scope"), "no masking either", and the "more than 5 commits behind" row of the failure-mode table. Could you update them, or should I?
ABI check result on 1fee65b: ✅ ABI Conformance(avx2) passed with __release_lnx-500d5ac… (exact: yes, 0 commits back). The baseline is the merge commit's first parent, and abidiff found nothing in the six libraries it compares. On c8d0188 the same job had logged exact: no together with the fallback warning.
|
@napetrov Thanks. I reviewed both commits and agree with them. |


Description
Two independent CI findings, both surfaced by CodeQL on the Actions workflows, plus a follow-up that takes cache access away from
Nightly-test(see the end of section 2).1.
ci-win.ymlhad nopermissions:blockIt was the only workflow in
.github/workflows/without one, so itspush: mainruns received the repository-default token for a job that only builds and tests. Set tocontents: read.2. The ABI check could compare a pull request against a tree that already contains its change
abidiffreports the difference between two trees, not what a pull request changed. For the report to equal the pull request's own delta, the baseline has to be an ancestor of the build under test. Main's tip is not one:checkoutbuilds the pull request's merge commit, which contains main only up to the pull request's base.How a real break passes:
M0. Pull request B is opened fromM0and removes exported symbolS.Stoo -- a re-land, a cherry-pick, or the other half of one refactor. A merges carryingAPI/ABI breaking change, so A's own ABI check never ran (the job'sif:skips it on that label).M1 = M0 + A, and its cache lands. B's baseline becomesM1, because the baseline was selected as the newest entry in main's cache scope.M0 + B.abidiff(M1, M0+B):Sis absent from both sides, so nothing is reported. B goes green and merges without the label.M1 + B. B's break is part of the baseline from then on and no run will ever see it.The nastier variant does not need the two changes to be identical. If A changed a struct layout (labelled, accepted) and B independently removes a method from that struct,
abidiffreports B's removal plus A's layout change inverted, and the job's own help text trains the reader to attribute unexplained diffs to baseline drift: "Keep the branch up to date with oneDAL 'main' as it may otherwise not contain the latest changes in the ABI that can lead to erroneous failures." Once drift is an accepted excuse, a real finding is dismissed along with the noise.Fix. Start from the first parent of the merge commit -- the main commit the build actually contains -- and walk its ancestors for a cached build. The baseline is then always an ancestor of the build, so main running ahead adds no drift. When the base commit itself is cached (the normal case), the diff contains only the pull request's own changes and nothing is masked. When the base's main build has not been cached yet, the newest cached ancestor is used: the main commits in between show up in the report, a change that undoes one of them is not reported, and the job says so in a
::warning::(rerun the job once main's CI for the base has finished).Selection moved to
.ci/scripts/abi_baseline_key.sh; the workflow step is one line.Also changed by the same rewrite: the cache listing no longer picks the baseline
The old selection was
gh cache list --sort created_at --limit 1, so whatever key was written to main's cache scope last became the baseline. Only trusted triggers (push,schedule,workflow_dispatch) can write there;workflow_run,pull_request_targetandissue_commentruns get read-only access.Nightly-test'sworkflow_dispatchruns did have write access, and this PR now setscache-mode: noneon that workflow (cache-mode: readforcve-bin-tool-scan).cache-mode: nonereplaces the per-step blanking ofACTIONS_RUNTIME_TOKEN,ACTIONS_RESULTS_URLandACTIONS_CACHE_URL, so thoseenv:blocks are removed from thebazel_lnxandbazel_winsteps, and the file'sCache accessnote is rewritten to match.Candidate keys are now derived from commits and only looked up in the cache listing, never read out of it. A writer could still re-create the key of a real main commit whose entry has expired, and the walk would pick it if nothing newer is cached; the restored tree is only ever read by
abidiff. Ordering by commit position also fixes a staleness bug in its own right: a cache created recently for an older main commit used to win over main's tip.Failure modes are all loud
::warning::naming what the comparison can then miss::error::and exit 1cache/restorecannot read itfail-on-cache-miss: true, unchangedabi_check.shprints::error:: No shared objects foundand exits 1, unchanged::error::and exit 1 (a caller bug, not a cache gap)gh cache listor the commit lookup fails::error::naming the failed call, "rerun this job", exit 1::warning::, so an inexact comparison is never silentNot covered here
Nothing checks the cumulative ABI delta against the last release. A labelled pull request is never checked and is then absorbed into every later baseline, so two breaking changes mean two labels and no gate that ever adds them up. That needs a released
.soas the baseline rather than a main cache, which is a separate job.Validation
.ci/scripts/abi_baseline_key.shwas exercised against main's real ancestry with a stubbedgh, covering: base cached exactly (0 back, silent); base cached 1+ ancestors back (warning); base uncached with only main's tip cached (falls back, warns); a poisoned invented key present alongside a valid one (ignored); a poisoned key only (hard error); empty cache listing (hard error); empty base (hard error); failed cache listing (hard error). All three changed workflows parse as YAML,cache-modeis placed at workflow and job level as the dependency caching reference allows, and the script passesbash -nandshellcheck.This pull request's own
ABI Conformance(avx2)run picked__release_lnx-500d5ac(exact: yes, 0 commits back), the first parent of its merge commit.I also confirmed against the live repository that the premise holds and the window is generous: 15 live
__release_lnxentries covering every one of main's last 15 commits, 50 MB each, so there is no eviction pressure and the only gap is the 7-day expiry.Checklist:
Completeness and readability
Testing