Skip to content

Add Coverage workflow #623

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 42 commits into from
Aug 22, 2025
Merged

Add Coverage workflow #623

merged 42 commits into from
Aug 22, 2025

Conversation

Daraan
Copy link
Contributor

@Daraan Daraan commented Jul 7, 2025

Added a coverage workflow (resolves: #520).

Currently the logic is to upload one coverage file from each version test.

pip install coverage & coverage -m unittest

A crucial point about this workflow is that it installs and runs the test with coverage -m unittest and not python -m unittest anymore. coverage does (currently) not install typing_extensions so I think that is a safe pip install (at the moment). I tried to add a weak test that assures that typing_extensions.__file__ is indeed the one in src/ and not site-packages, but that test fails on the pypi installation


Do you prefer any changes here? e.g. run first with python when install and run again with coverage?

Does anyone know why the pypy 3.9 and 3.10 tests fail here? Some kind of code leakage, missing monkeypatch?

EDIT:

  • Changed to not run coverage on the pypi versions

@AlexWaygood
Copy link
Member

if it's a pain to run PyPy tests under coverage, I think it would be fine to do the pypy tests as a separate CI job that aren't run under coverage. None of our code is currently PyPy-specific (we had a workaround for a PyPy bug for a while but it was a tiny branch of code).

Copy link
Member

@AlexWaygood AlexWaygood left a comment

Choose a reason for hiding this comment

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

Nice, thank you!

@AlexWaygood
Copy link
Member

Could you fix the pre-commit failures?

Copy link
Member

@AlexWaygood AlexWaygood left a comment

Choose a reason for hiding this comment

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

LGTM if we can get the PR comment thing working! (I don't think I have the necessary permissions for that either)

@Daraan
Copy link
Contributor Author

Daraan commented Jul 7, 2025

According to https://docs.github.com/actions/how-tos/security-for-github-actions/security-guides/automatic-token-authentication, a PR from a fork can never have write access (expect the repo itself).

A relevant bit:

Finally, if the workflow was triggered by a pull request from a forked repository, and the Send write tokens to workflows from pull requests setting is not selected, the permissions are adjusted to change any write permissions to read only.


Maintainers note from the action on the error: https://github.com/marocchino/sticky-pull-request-comment/tree/v2/#error-resource-not-accessible-by-integration

check your Settings > Actions > General > Workflow permissions, and make sure to enable read and write permissions.

The alternative is to provide a GITHUB_TOKEN (defaults to ${{ github.token }}) currently.

@Daraan
Copy link
Contributor Author

Daraan commented Aug 13, 2025

@JelleZijlstra I think we need your decision / help here to finish this PR.
If we want to have a coverage report message the action needs write permission on the PR, see links above for the options.
We could also use codecov. I thought it also required a secret token, on a second look it might also without (on public repos). It comes with its own pros and cons.

@JelleZijlstra
Copy link
Member

Read and write permissions for workflows are already enabled, so this might work once we merge it into main?

Copy link
Contributor Author

@Daraan Daraan left a comment

Choose a reason for hiding this comment

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

Read and write permissions for workflows are already enabled, so this might work once we merge it into main?

I had the same thought as well. But couldn't find a definite answer yet.

On my fork I did made a PR to a branch with the coverage workflow already in it: Daraan#1 (comment) there it commented like expected on the PR.
However, if I add a PR too my main branch Daraan#2 it also comments -might be because it is my own PR 🤔?

If: Settings > Actions > General > Workflow permissions is read & write. Merge and test with a dummy PR as follow up if it really works?

@Daraan Daraan marked this pull request as ready for review August 18, 2025 16:05
@Daraan
Copy link
Contributor Author

Daraan commented Aug 18, 2025

I've changed the code to a working variant. However, it should be reviewed carefully as we would need to add an exception:

zizmor's warning is correct that workflow_run can be unsafe: See https://docs.zizmor.sh/audits/#dangerous-triggers

Here is a good article about it that I followed to create the new variant: https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/ to avoid insecure pitfalls.

The most important bit, coverage_report.yml does not use execute user code, i.e. code checkout and code run.
The new workflow (and any modifications of it) will only be active after a merge. Any future PR modifying it should be reviewed carefully.

The new workflow consists of github actions and one variable write:

  • download a file containing the PR number and store it in the env (more below)
  • download the coverage report formatted as markdown
  • post the markdown in the respective PR number

zizmor does not report a potentially dangerous GITHUB_ENV write https://docs.zizmor.sh/audits/#github-env

However a user could store what they want in pr_number.txt. Potentially a guard checking that it only contains a small number (so it has to be a PR number) should be added.


As a conclusion to feel save would be to ask someone who is more knowledgeable about the risk to have a look and make a review

Following the guide one could rewrite the workflow to only use github scripts to extract the PR number and write the comment. This would save the call to modify GITHUB_ENV

@AlexWaygood
Copy link
Member

As a conclusion to feel save would be to ask someone who is more knowledgeable about the risk to have a look and make a review

@woodruffw -- don't suppose you could give this PR a once over from a security perspective, could you? typing_extensions is a top-10 PyPI package, so it's probably better for us to be safe than sorry here!

@woodruffw
Copy link

Thanks for the ping @AlexWaygood! Yeah, I'll be able to do a review of this in a moment.

@woodruffw
Copy link

woodruffw commented Aug 18, 2025

Okay, I did a quick pass over this. I think @Daraan is right that this can't execute arbitrary code from a third-party fork with unintentional privileges.

However, this is susceptible to another kind of confusion/delegation issue that's typical with workflow_run:

  • The "called" workflow (coverage_report.yml) uses "Test and lint" as its caller;
  • However, any PR can name any workflow like that, which means that effectively any workflow (and not just the "trusted" caller) can invoke coverage_report.yml.

In practice, what that means is that someone can submit a PR containing foo.yml with name: Test and lint, and foo.yml will fully control a dispatch into coverage_report.yml. In this particular case that means that foo.yml will control both pr_number.txt and the generated Markdown fully (via its artifact), meaning that they can insert comments on PRs other than the one that they opened.

As far as I can tell, there's unfortunately no really good way to workaround that with workflow_run itself -- the ideal situation is that the workflow_run is only restricted to "trusted" triggers, but in this case the goal is explicitly untrusted ones (like pull_request). The other workaround would be to somehow plug a trusted input into it (like a label action, since only maintainers can do those), but there's no real way to forward those into a workflow_run.

TL;DR: A malicious PR creator can use this to change coverage results on other PRs, or change the markdown to anything they please (probably mostly as a griefing vector that's hard to moderate, since it's a non-user commenter). My suggestion would be to keep the coverage check, but to either limit the comment flow to first-party (i.e. non-fork) PRs or remove the comment flow entirely (since admittedly it isn't very useful if it's only on non-fork PRs).

@Daraan
Copy link
Contributor Author

Daraan commented Aug 18, 2025

Thank you very much for the review. That is kind of the scenario I see as well.

Some new thoughts that I had in the meantime:

  • GITHUB_OUTPUT can like be used instead of ENV
  • pip install coverage and merging the coverage files in this workflow seems save. Iirc correctly coverage needs access to the source. Adding the source files as artifact would work as well. Then an attacker has no longer reign over the markdown file.
  • I did not look into pull_request_target yet as more can go wrong there, but as it access to the PR number the upload of the PR is also taken from the PR creator.

Is there any difference to adding a new foo.yml with the same workflow name compared to just rewriting the existing ci file? Both are in the hand of the PR creator.

@woodruffw
Copy link

  • pip install coverage and merging the coverage files in this workflow seems save. Iirc correctly coverage needs access to the source. Adding the source files as artifact would work as well. Then an attacker has no longer reign over the markdown file.

Making sure I understand: you're proposing merging the coverage files in the workflow_run instead? I think that would eliminate the immediate griefing issue, but it doesn't prevent the underlying one (where the PR number itself is entirely attacker controlled). I think it's also a bit risky, since I'm not sure coverage combine is guaranteed to be safe to run on untrusted inputs (which the coverage inputs would then become).

I did not look into pull_request_target yet as more can go wrong there, but as it access to the PR number the upload of the PR is also taken from the PR creator.

Yeah, unfortunately I think pull_request_target is equally risky -- it doesn't have exactly the same footgun as workflow_run around the PR number, but it requires executing the test suite in a privileged context and that seems pretty risky.

Is there any difference to adding a new foo.yml with the same workflow name compared to just rewriting the existing ci file? Both are in the hand of the PR creator.

Yeah, those two are 100% equivalent, I just said foo.yml to be generic 🙂 -- the underlying problem is fundamentally that the "input" to a workflow_run is 100% untrusted unless you constrain its triggers, which in this case can't easily be done.

@JelleZijlstra
Copy link
Member

Thanks for taking a look! I think the immediate vulnerability (attackers can post comments on random PRs) is fairly harmless, but it does feel like there's a risk attackers could do other sketchy things that we haven't thought of, or that this vulnerability could compound with permissions we grant in other GitHub Actions.

As an alternative, could we forego the comment workflow and just have the coverage check print out the result in its action output, and fail if coverage is below some threshold?

@AlexWaygood
Copy link
Member

As an alternative, could we forego the comment workflow and just have the coverage check print out the result in its action output, and fail if coverage is below some threshold?

this also has the advantage of probably simplifying the workflow a fair bit :-)

@woodruffw
Copy link

Yeah, I think having the output stick to the run console is probably the way to go in terms of access control! I wish there was a way to grant a "let this untrusted job post exactly one comment to this exact PR" token in GHA, but alas 😅

@Daraan
Copy link
Contributor Author

Daraan commented Aug 19, 2025

I've reworked the crucial parts again:

Do you want to give it another look?

As an alternative, could we forego the comment workflow and just have the coverage check print out the result in its action output, and fail if coverage is below some threshold?

Easily possible :)

runs-on: ubuntu-latest
if: >
github.event.workflow_run.event == 'pull_request'
&& github.repository == github.event.workflow_run.repository.full_name

Choose a reason for hiding this comment

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

Unfortunately I don't think this works -- workflow_run always runs in the context of the "parent" repository, so the second hand of this && expression always evaluate to true (I think).

Choose a reason for hiding this comment

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

(But also, if the intention is to limit this to first-party PRs, then I think this workflow_run can be removed entirely and you can move these steps with their first-party check to ci.yml instead 🙂)

Copy link
Member

Choose a reason for hiding this comment

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

For what it's worth, if this works only for first-party PRs that would be a dealbreaker to me; we get lots of third-party PRs and even maintainer PRs often come from forks.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I wanted to follow zizmors advise below, in no way it should inhibit any PRs that are made onto here.

If you have to use a dangerous trigger, consider adding a github.repository == ... check to only run for your repository but not in forks of your repository (in case the user has enabled Actions there). This avoids exposing forks to danger in case you fix a vulnerability in the workflow but the fork still contains an old vulnerable version.

I ported their suggestion on github.actor == 'dependabot[bot]' && github.repository == github.event.pull_request.head.repo.full_name and ported it.
envoyproxy implements such a guard in two of its repositories.
https://github.com/envoyproxy/toolshed/blob/f70658cd0445b7d763107eaebde543be927c4f7a/gh-actions/github/env/load/action.yml#L45
and in envoy. Beside that it does not appear to be a common guard.

However, possibly they meant a hardcoded repository string, i.e. python/typing_extensions.
On third thought I now also think it might not make sense but leaving it here will have no negative consequences for this repo and all PRs against it.

Choose a reason for hiding this comment

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

I wanted to follow zizmors advise below, in no way it should inhibit any PRs that are made onto here.

If you have to use a dangerous trigger, consider adding a github.repository == ... check to only run for your repository but not in forks of your repository (in case the user has enabled Actions there). This avoids exposing forks to danger in case you fix a vulnerability in the workflow but the fork still contains an old vulnerable version.

This is my fault -- that advice is only correct for pull_request_target, not for workflow_run 😅. I'll file a bug to fix those docs later today.

(This is another datapoint for workflow_run being quite a footgun -- I think GitHub at one point also suggested this as a mitigation, which is where I got it from, without realizing that it doesn't actually protect anything on that trigger.)

- name: Get PR number
id: get_pr_number
env:
SHA: "${{ github.event.workflow_run.head_sha }}"

Choose a reason for hiding this comment

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

Do you have any docs for this field? I couldn't find anything that clearly documents that it would point to the HEAD to the PR's branch, rather than the HEAD of the main branch.

(It's also not clear to me how this works when PRs contain some shared history or have identical commits due to cherry picking, so this feels pretty risky.)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

https://docs.github.com/de/webhooks/webhook-events-and-payloads#workflow_run does not exactly specify it.
From my tests it always points to the latest commit in a PR which triggered the ci workflow.

For example: me as 3rd party no fork without access:
dsp-unima#9

A reference I found is here:
https://github.com/marketplace/actions/commit-hash

workflow_run events are handled by Workflows within the context of the main branch, therefore the github.sha context value does not represent the commit that triggered the Workflow and we must use the head_sha value on the event instead.

Copy link
Contributor Author

@Daraan Daraan left a comment

Choose a reason for hiding this comment

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

I've did another slight rework that only uses the elevated write privilege in the final step

runs-on: ubuntu-latest
if: >
github.event.workflow_run.event == 'pull_request'
&& github.repository == github.event.workflow_run.repository.full_name
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I wanted to follow zizmors advise below, in no way it should inhibit any PRs that are made onto here.

If you have to use a dangerous trigger, consider adding a github.repository == ... check to only run for your repository but not in forks of your repository (in case the user has enabled Actions there). This avoids exposing forks to danger in case you fix a vulnerability in the workflow but the fork still contains an old vulnerable version.

I ported their suggestion on github.actor == 'dependabot[bot]' && github.repository == github.event.pull_request.head.repo.full_name and ported it.
envoyproxy implements such a guard in two of its repositories.
https://github.com/envoyproxy/toolshed/blob/f70658cd0445b7d763107eaebde543be927c4f7a/gh-actions/github/env/load/action.yml#L45
and in envoy. Beside that it does not appear to be a common guard.

However, possibly they meant a hardcoded repository string, i.e. python/typing_extensions.
On third thought I now also think it might not make sense but leaving it here will have no negative consequences for this repo and all PRs against it.

- name: Get PR number
id: get_pr_number
env:
SHA: "${{ github.event.workflow_run.head_sha }}"
Copy link
Contributor Author

Choose a reason for hiding this comment

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

https://docs.github.com/de/webhooks/webhook-events-and-payloads#workflow_run does not exactly specify it.
From my tests it always points to the latest commit in a PR which triggered the ci workflow.

For example: me as 3rd party no fork without access:
dsp-unima#9

A reference I found is here:
https://github.com/marketplace/actions/commit-hash

workflow_run events are handled by Workflows within the context of the main branch, therefore the github.sha context value does not represent the commit that triggered the Workflow and we must use the head_sha value on the event instead.

@Daraan
Copy link
Contributor Author

Daraan commented Aug 19, 2025

it's also not clear to me how this works when PRs contain some shared history or have identical commits due to cherry picking, so this feels pretty risky.)

I do not see a problem in cherry picks as the sha is important which will not be equal after picking.

A shared history and two PRs ABC and AB could exist. If B is the last commit in a PR. We could not disambiguate which is the triggering PR (ABC or AB). In that case abort.

@AlexWaygood
Copy link
Member

I think at this point I'd really value taking an incremental approach here:

  • Let's first land a simple workflow that just prints the coverage report to the terminal and fails CI if coverage drops under a certain percentage
  • After we land that, we can open a followup issue/PR to investigate if there's a secure way for us to have a comment workflow (which would certainly be nice, but doesn't seem essential)

@Daraan
Copy link
Contributor Author

Daraan commented Aug 20, 2025

I think at this point I'd really value taking an incremental approach here:

* Let's first land a simple workflow that just prints the coverage report to the terminal and fails CI if coverage drops under a certain percentage

* After we land that, we can open a followup issue/PR to investigate if there's a secure way for us to have a comment workflow (which would certainly be nice, but doesn't seem _essential_)

Lets do that for now.

I removed the second workflow_run file. I've kept the comment support in for now, it will only apply to 1st party PRs, i.e. branches that are on the repo itself - so if you do a PR on your own fork you will receive the comment.
Ofc I can remove the action fully if you prefer.


EDIT:

I set the thresholds to 90 / 95. Below 90% it would fail.

Currently we are 97%, despite some branches missing in the 3.9 and 3.10 range that are not run in the current tests.

Copy link
Member

@AlexWaygood AlexWaygood left a comment

Choose a reason for hiding this comment

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

This seems reasonable to me, thank you!

@AlexWaygood AlexWaygood merged commit ac80bb7 into python:main Aug 22, 2025
23 checks passed
@AlexWaygood
Copy link
Member

AlexWaygood commented Aug 22, 2025

Tested it out in #651 and #652 and it seems to be working well -- thanks @Daraan!!

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.

Add test coverage monitoring
4 participants