This is an automated email from the ASF dual-hosted git repository. sjanc pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/mynewt-nimble.git
commit 751b5dcc3f86332cd33ed213730114efef65b9de Author: Szymon Janc <[email protected]> AuthorDate: Wed Oct 14 00:58:14 2026 +0200 ci: Replace pull_request_target workflows with scheduled one Labeling, CI approval label and workflows check workflows used pull_request_target, which runs in privileged context for every pull request event. Replace them with single workflow scheduled on master which handles open pull requests through API only, without using any pull request code. This is same as in apache-mynewt-core. CI approval label is now set based on workflow runs waiting for approval instead of author permissions, and is removed once runs are approved. Size labeler supports only pull request events and is kept in labeler workflow, without checking out any code. Assisted-by: Claude:claude-opus-5-5 --- .github/scripts/pr_maintenance.py | 335 ++++++++++++++++++++++++++++++++++ .github/workflows/add_ci_label.yml | 94 ---------- .github/workflows/check_workflows.yml | 73 -------- .github/workflows/labeler.yml | 13 +- .github/workflows/pr_maintenance.yml | 79 ++++++++ 5 files changed, 417 insertions(+), 177 deletions(-) diff --git a/.github/scripts/pr_maintenance.py b/.github/scripts/pr_maintenance.py new file mode 100644 index 000000000..20f0b4dfd --- /dev/null +++ b/.github/scripts/pr_maintenance.py @@ -0,0 +1,335 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. +# + +""" +Periodic maintenance of open pull requests, run by scheduled workflow on +master. Pull request code is never checked out or executed, everything +is done through GitHub API using gh tool. + +- 'needs-ci-approval' label is set on pull requests with workflow runs + waiting for approval on their head commit, and removed otherwise. +- 'Check workflows' commit status reports whether .github/workflows of + pull request head matches base branch ('workflows-update' label skips + the check). +- Pull requests with new head commit are reported for path based + labeling, which is done by actions/labeler in the workflow. + +'Check workflows' status also marks pull request head as processed: it +is tied to head commit and its description contains base branch +workflows tree it was checked against, so only new or changed pull +requests are processed. + +Usage: + pr_maintenance.py check - update labels, write step outputs + pr_maintenance.py report - report statuses from STATUSES variable +""" + +from datetime import date, timedelta +import json +import os +import subprocess +import sys + +CI_LABEL = "needs-ci-approval" +WORKFLOWS_LABEL = "workflows-update" +STATUS_CONTEXT = "Check workflows" +SKIPPED = f"Skipped, '{WORKFLOWS_LABEL}' label present" + +# Runs never approved stay waiting forever, skip old ones +CI_RUNS_MAX_AGE_DAYS = 30 + +# Limit API usage, remaining pull requests are handled by next runs +MAX_PULLS_PER_RUN = 20 + +# Workflows trees are queried with object() which returns null if path +# doesn't exist, Commit.file() fails whole query (and gh pagination) instead +TREES_PER_QUERY = 100 + +REPO = os.environ.get("GITHUB_REPOSITORY", "") + +PULLS_QUERY = """ +query ( + $owner: String! + $repo: String! + $context: String! + $endCursor: String +) { + repository(owner: $owner, name: $repo) { + pullRequests( + states: OPEN + first: 50 + after: $endCursor + orderBy: { field: UPDATED_AT, direction: DESC } + ) { + pageInfo { + hasNextPage + endCursor + } + nodes { + number + headRefOid + baseRefName + labels(first: 100) { + nodes { + name + } + } + baseRef { + target { + oid + } + } + commits(last: 1) { + nodes { + commit { + oid + status { + context(name: $context) { + state + description + } + } + } + } + } + } + } + } +} +""" + +TREES_QUERY = """ +query ($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { +%s + } +} +""" + +TREE_FIELD = 'c%d: object(expression: "%s:.github/workflows") { oid }' + + +def gh(*args): + """Runs gh command, returns its output""" + return subprocess.run(["gh", *args], check=True, capture_output=True, + text=True).stdout + + +def error(message): + print(f"::error::{message}") + + +def workflows_trees(commits): + """Returns dict of commit to its .github/workflows tree (or None)""" + owner, repo = REPO.split("/") + commits = sorted(set(commits)) + trees = {} + + for i in range(0, len(commits), TREES_PER_QUERY): + chunk = commits[i:i + TREES_PER_QUERY] + fields = "\n".join(TREE_FIELD % (n, c) for n, c in enumerate(chunk)) + data = json.loads(gh("api", "graphql", + "-f", f"query={TREES_QUERY % fields}", + "-f", f"owner={owner}", "-f", f"repo={repo}")) + result = data["data"]["repository"] + for n, c in enumerate(chunk): + trees[c] = (result[f"c{n}"] or {}).get("oid") + + return trees + + +def list_pulls(): + owner, repo = REPO.split("/") + pages = json.loads(gh("api", "graphql", "--paginate", "--slurp", + "-f", f"query={PULLS_QUERY}", + "-f", f"owner={owner}", "-f", f"repo={repo}", + "-f", f"context={STATUS_CONTEXT}")) + pulls = [] + + for page in pages: + for node in page["data"]["repository"]["pullRequests"]["nodes"]: + commit = node["commits"]["nodes"][0]["commit"] + # Commits list is limited, skip pull request if head is missing + if commit["oid"] != node["headRefOid"]: + print(f"#{node['number']}: head commit not found, skipping") + continue + + base = (node["baseRef"] or {}).get("target") or {} + pulls.append({ + "number": node["number"], + "head": commit["oid"], + "labels": [l["name"] for l in node["labels"]["nodes"]], + "status": (commit["status"] or {}).get("context"), + "base_name": node["baseRefName"], + "base_head": base.get("oid"), + }) + + trees = workflows_trees([p["head"] for p in pulls] + + [p["base_head"] for p in pulls if p["base_head"]]) + + for pull in pulls: + base_head = pull.pop("base_head") + base_name = pull.pop("base_name") + base_tree = trees.get(base_head) + pull["tree"] = trees[pull["head"]] + pull["base_tree"] = base_tree + pull["base"] = f"{base_name}@{(base_tree or 'none')[:12]}" + + return pulls + + +def update_ci_label(pulls): + """Returns number of errors""" + since = date.today() - timedelta(days=CI_RUNS_MAX_AGE_DAYS) + try: + waiting = set(gh("api", "--paginate", + f"repos/{REPO}/actions/runs?status=action_required" + f"&created=>={since.isoformat()}&per_page=100", + "--jq", ".workflow_runs[].head_sha").split()) + except subprocess.CalledProcessError as e: + error(f"Failed to list workflow runs: {e.stderr.strip()}") + return 1 + + issue = f"repos/{REPO}/issues" + label_created = False + errors = 0 + + for pull in pulls: + has_label = CI_LABEL in pull["labels"] + if (pull["head"] in waiting) == has_label: + continue + + try: + if has_label: + gh("api", "-X", "DELETE", + f"{issue}/{pull['number']}/labels/{CI_LABEL}") + print(f"#{pull['number']}: removed '{CI_LABEL}' label") + else: + if not label_created: + gh("label", "create", CI_LABEL, "-R", REPO, "--force", + "--color", "E3650b") + label_created = True + gh("api", "-X", "POST", f"{issue}/{pull['number']}/labels", + "-f", f"labels[]={CI_LABEL}") + print(f"#{pull['number']}: added '{CI_LABEL}' label") + except subprocess.CalledProcessError as e: + error(f"#{pull['number']}: {e.stderr.strip()}") + errors += 1 + + return errors + + +def workflows_status(pull): + """Returns status to report, or None if reported one is up to date""" + status = pull["status"] + + if WORKFLOWS_LABEL in pull["labels"]: + new = {"state": "success", "description": SKIPPED} + elif pull["tree"] == pull["base_tree"]: + new = {"state": "success", + "description": f"Workflows match {pull['base']}"} + else: + new = {"state": "failure", + "description": f"Workflows differ from {pull['base']}, " + f"rebase or apply '{WORKFLOWS_LABEL}' label"} + + if status and (status["state"], status["description"]) == \ + (new["state"], new["description"]): + return None + + return {"sha": pull["head"], **new} + + +def check(): + """ + Updates CI approval label and returns number of errors and step + outputs: + pr-numbers - newline separated pull requests for path based labeling + statuses - JSON list of workflows commit statuses to report + """ + pulls = list_pulls() + print(f"{len(pulls)} open pull requests") + errors = update_ci_label(pulls) + statuses = [s for s in map(workflows_status, pulls) if s] + statuses = statuses[:MAX_PULLS_PER_RUN] + reported = {s["sha"] for s in statuses} + + # No status on head commit means pull request was updated + labels = [str(p["number"]) for p in pulls + if not p["status"] and p["head"] in reported] + + for status in statuses: + print(f"{status['sha']}: {status['description']}") + + return errors, { + "pr-numbers": "\n".join(labels), + "statuses": json.dumps(statuses), + } + + +def report(statuses, target_url): + """Reports statuses computed by check(), returns number of errors""" + errors = 0 + + for status in statuses: + try: + gh("api", "-X", "POST", f"repos/{REPO}/statuses/{status['sha']}", + "-f", f"state={status['state']}", + "-f", f"description={status['description']}", + "-f", f"context={STATUS_CONTEXT}", + "-f", f"target_url={target_url}") + print(f"{status['sha']}: {status['state']}") + except subprocess.CalledProcessError as e: + error(f"{status['sha']}: {e.stderr.strip()}") + errors += 1 + + return errors + + +def write_outputs(outputs): + with open(os.environ["GITHUB_OUTPUT"], "a") as f: + for key, value in outputs.items(): + if "\n" in value: + f.write(f"{key}<<EOF\n{value}\nEOF\n") + else: + f.write(f"{key}={value}\n") + + +def main(): + if sys.argv[1:] == ["check"]: + try: + errors, outputs = check() + except subprocess.CalledProcessError as e: + sys.exit(f"Failed to list pull requests: {e.stderr.strip()}") + # Outputs are written even on errors so following steps can + # handle pull requests that were processed successfully + write_outputs(outputs) + elif sys.argv[1:] == ["report"]: + url = f"{os.environ['GITHUB_SERVER_URL']}/{REPO}/actions/runs/" \ + f"{os.environ['GITHUB_RUN_ID']}" + errors = report(json.loads(os.environ["STATUSES"]), url) + else: + sys.exit(__doc__) + + if errors: + sys.exit(f"{errors} error(s) occurred") + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/add_ci_label.yml b/.github/workflows/add_ci_label.yml deleted file mode 100644 index 3bff33c37..000000000 --- a/.github/workflows/add_ci_label.yml +++ /dev/null @@ -1,94 +0,0 @@ -# -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you 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. -# - -name: Informative CI status - -on: - pull_request_target: - types: [opened, ready_for_review, reopened, synchronize] - -permissions: - contents: read - issues: write - pull-requests: write - -jobs: - add-label: - runs-on: ubuntu-slim - steps: - - uses: actions/github-script@v7 - with: - script: | - const pr = context.payload.pull_request; - - // Get author's effective repo permission: admin|maintain|write|triage|read|none - let permission = 'unknown'; - try { - const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ - ...context.repo, - username: pr.user.login, - }); - permission = data.permission || 'unknown'; - } catch (e) { - permission = 'none'; - core.warning(`Could not fetch collaborator permission: ${e.status || ''} ${e.message}`); - } - - const trusted = ['admin','maintain','write'].includes(permission); - - const info = { - number: pr.number, - title: pr.title, - author: pr.user.login, - author_association: pr.author_association, - author_permission: permission, - trusted_by_permission: trusted, - base_repo: pr.base.repo.full_name, - head_repo: pr.head.repo.full_name, - is_fork: !!pr.head.repo.fork, - }; - core.info('PR author info:\n' + JSON.stringify(info, null, 2)); - - // Only add the label if the author does NOT have write-level permission - if (!trusted) { - const label = 'needs-ci-approval'; - try { - // Ensure the label exists (422 = already exists) - try { - await github.request('POST /repos/{owner}/{repo}/labels', { - ...context.repo, - name: label, - color: 'E3650b', - }); - } catch (e) { - if (e.status !== 422) throw e; - } - - await github.rest.issues.addLabels({ - ...context.repo, - issue_number: context.issue.number, - labels: [label], - }); - core.info(`Added '${label}' to PR #${context.issue.number}`); - } catch (e) { - core.setFailed(`Failed to label PR: ${e.status || ''} ${e.message}`); - } - } else { - core.info('Author has write-level permission; not adding label.'); - } diff --git a/.github/workflows/check_workflows.yml b/.github/workflows/check_workflows.yml deleted file mode 100644 index 6c1dc46d4..000000000 --- a/.github/workflows/check_workflows.yml +++ /dev/null @@ -1,73 +0,0 @@ -# -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you 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. -# - -name: Check workflows status - -on: - pull_request_target: - types: [opened, synchronize, reopened, labeled, unlabeled] - -permissions: - pull-requests: read - contents: read - -jobs: - check-workflows: - runs-on: ubuntu-slim - steps: - - name: Check out master branch (base) - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.base.ref }} - path: master_branch - - - name: Check out PR branch (head) - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.sha }} - path: pr_branch - allow-unsafe-pr-checkout: true - persist-credentials: false - - - name: Check for label override - id: label_check - uses: actions/github-script@v7 - with: - script: | - const labelName = 'workflows-update'; - const labels = await github.rest.issues.listLabelsOnIssue({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.payload.pull_request.number, - }); - const hasLabel = labels.data.some(label => label.name === labelName); - core.setOutput('skip', hasLabel); - - - name: Compare workflows between master and PR - if: steps.label_check.outputs.skip != 'true' - run: | - echo "Checking if .github/workflows/ differs from master..." - diff -r master_branch/.github/workflows pr_branch/.github/workflows || { - echo "::error::Workflows differ from master. Please rebase or apply 'workflows-update' label to bypass."; - exit 1; - } - - - name: Skip message if label present - if: steps.label_check.outputs.skip == 'true' - run: echo "Skipping workflows check because 'workflows-update' label is present." diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 9c5950d56..465f8b2a6 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -17,7 +17,7 @@ # under the License. # -name: "Pull Request Labeler" +name: "Pull Request Size Labeler" on: - pull_request_target @@ -27,17 +27,10 @@ jobs: contents: read pull-requests: write issues: write + # pr-size-labeler is Docker action which is not supported by + # ubuntu-slim runner runs-on: ubuntu-latest steps: - - name: Checkout repository - uses: actions/checkout@v7 - - - name: Assign labels based on paths - uses: actions/labeler@v7 - with: - sync-labels: true - configuration-path: .github/labeler_cfg.yml - - name: Assign labels based on the PR's size uses: codelytv/[email protected] with: diff --git a/.github/workflows/pr_maintenance.yml b/.github/workflows/pr_maintenance.yml new file mode 100644 index 000000000..5d5c0b323 --- /dev/null +++ b/.github/workflows/pr_maintenance.yml @@ -0,0 +1,79 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. +# + + +# Maintenance of open pull requests (labels and workflows status check). +# This runs periodically from master instead of pull_request_target, so +# no pull request code or configuration is used with elevated privileges. + +name: Pull request maintenance + +on: + schedule: + - cron: '*/5 * * * *' + workflow_dispatch: + +permissions: + actions: read + contents: read + issues: write + pull-requests: write + statuses: write + +concurrency: + group: ${{ github.workflow }} + +jobs: + maintenance: + if: github.repository == 'apache/mynewt-nimble' + runs-on: ubuntu-slim + # Shorter than schedule interval, so that hung run doesn't delay + # following ones + timeout-minutes: 4 + steps: + - uses: actions/checkout@v7 + with: + sparse-checkout: .github + persist-credentials: false + + - name: Check pull requests + id: check + env: + GH_TOKEN: ${{ github.token }} + run: python3 .github/scripts/pr_maintenance.py check + + # Following steps run also if previous step failed, so that error in + # one pull request doesn't block processing of others + + - name: Assign labels based on paths + if: ${{ !cancelled() && steps.check.outputs.pr-numbers != '' }} + uses: actions/labeler@v7 + with: + pr-number: ${{ steps.check.outputs.pr-numbers }} + sync-labels: true + configuration-path: .github/labeler_cfg.yml + + - name: Report workflows status + if: >- + ${{ !cancelled() && steps.check.outputs.statuses != '' && + steps.check.outputs.statuses != '[]' }} + env: + GH_TOKEN: ${{ github.token }} + STATUSES: ${{ steps.check.outputs.statuses }} + run: python3 .github/scripts/pr_maintenance.py report
