Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,3 +145,33 @@ ansible-playbook -vv -i inventory -e update_files_commit_file=/path/to/git-commi
NOTE: This process may create multiple commits if you need to make edits to an
existing PR. Use the `Squash commits and merge` functionality in the github PR
to merge.

# Manage Branch Protection Rules

The playbook `playbooks/update_branch_protection_rules.yml` is used to manage
branch protection rules for the `main` branch. The default rules are specified
in `branchProtectionRules` in `inventory/group_vars/active_roles.yml`. The
additional status checks for python code are specified in
`python_status_check_contexts` in `inventory/group_vars/python_roles.yml`. The
additional status checks for shell code are specified in
`shellcheck_status_check_contexts` in
`inventory/group_vars/shellcheck_roles.yml`.

These use the github graphql api https://docs.github.com/en/graphql. The
graphql files are in the `playbooks/graphql_files/` directory.

The general strategy is to get the existing role rules. If they are different
than the specified rules, delete them and add the specified rules.

There aren't any parameters, but if you use `-vvv` or higher verbosity,
you can see the data being sent and received.

*NOTE WELL*: This playbook does not create PRs - it modifies the role
configuration directly - so be careful.

## Run it

Run it like this:
```
ansible-playbook -vv -i inventory playbooks/update_branch_protection_rules.yml
```
36 changes: 36 additions & 0 deletions inventory/group_vars/active_roles.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,39 @@ lsr_name: linux_system_roles
lsr_role_namespace: linux_system_roles # for ansible-lint
gha_checkout_action: actions/checkout@v3
tox_lsr_url: "git+https://github.com/linux-system-roles/tox-lsr@2.13.2"
default_status_check_contexts:
- ansible_lint
- ansible_managed_var_comment
- ansible_plugin_scan
- ansible_test
- Fedora-37/ansible-2.14/(citool)
- Fedora-37/ansible-2.9/(citool)
- Fedora-36/ansible-2.14/(citool)
- Fedora-36/ansible-2.9/(citool)
# https://docs.github.com/en/graphql/reference/objects#branchprotectionrule
isAdminEnforced: false
branchProtectionRules:
- allowsDeletions: false
allowsForcePushes: false
blocksCreations: false
dismissesStaleReviews: true
isAdminEnforced: "{{ isAdminEnforced }}"
lockAllowsFetchAndMerge: false
lockBranch: false
pattern: main
requireLastPushApproval: true
requiredApprovingReviewCount: 1
requiredStatusCheckContexts: "{{ default_status_check_contexts +
status_check_contexts | d([]) +
python_status_check_contexts | d([]) +
shellcheck_status_check_contexts | d([]) | unique |
sort(case_sensitive=false) }}"
requiresApprovingReviews: true
requiresCodeOwnerReviews: true
requiresCommitSignatures: true
requiresConversationResolution: false
requiresLinearHistory: true
requiresStatusChecks: true
requiresStrictStatusChecks: true
restrictsPushes: false
restrictsReviewDismissals: false
9 changes: 9 additions & 0 deletions inventory/group_vars/python_roles.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,12 @@ present_python_templates:
- .github/workflows/codeql.yml
- .github/workflows/python-unit-test.yml
absent_python_files: []
# these are additional status checks for python code
python_status_check_contexts:
- CodeQL
- python (2.7, ubuntu-20.04)
- python (3.6, ubuntu-20.04)
- python (3.8, ubuntu-latest)
- python (3.9, ubuntu-latest)
- python (3.10, ubuntu-latest)
- python (3.11, ubuntu-latest)
3 changes: 3 additions & 0 deletions inventory/group_vars/shellcheck_roles.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,6 @@ present_shellcheck_templates:
- .github/workflows/shellcheck.yml
absent_shellcheck_files:
- .github/workflows/differential-shellcheck.yml
# these are additional status checks for shell code
shellcheck_status_check_contexts:
- shellcheck
2 changes: 2 additions & 0 deletions inventory/host_vars/cockpit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ github_actions:
weekly_ci:
schedule:
- cron: "0 12 * * 6"
# branch protection
isAdminEnforced: true
9 changes: 9 additions & 0 deletions inventory/host_vars/network.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,12 @@ yamllint:
/tests/tests_bond_cloned_mac_initscripts.yml
/tests/tests_bridge_cloned_mac_initscripts.yml
/tests/tests_bridge_cloned_mac_nm.yml
# these are added to the list of default status check contexts
status_check_contexts:
- DCO
- markdownlint
- integration (c8s)
- integration (c9s)
- integration (c7)
- python-26
- woke
Comment on lines +49 to +55
Copy link
Member

@tyll tyll Feb 18, 2023

Choose a reason for hiding this comment

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

Suggested change
- DCO
- markdownlint
- integration (c8s)
- integration (c9s)
- integration (c7)
- python-26
- woke
- DCO
- markdownlint
- integration (c8s)
- integration (c9s)
- integration (c7)
- python-26
- woke
- Analyze (python)

Currently, the UI also shows a source for each check (DCO, GitHub Actions, GitHub Code Scanning) depending on the status, the alternative seems to be "any source" which might be what this will be configuring, since it does not collect the source. It would be great to keep the sources as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The ansible_lint etc. are the defaults applied to all roles - so the network.yml contains only those status checks unique (currently) to network

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Currently, the UI also shows a source for each check (DCO, GitHub Actions, GitHub Code Scanning) depending on the status, the alternative seems to be "any source" which might be what this will be configuring, since it does not collect the source. It would be great to keep the sources as well.

Why keep the sources?

Copy link
Member

Choose a reason for hiding this comment

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

Currently, the UI also shows a source for each check (DCO, GitHub Actions, GitHub Code Scanning) depending on the status, the alternative seems to be "any source" which might be what this will be configuring, since it does not collect the source. It would be great to keep the sources as well.

Why keep the sources?

My assumption is that there is that there might be a security impact (maybe not that big since it seems to require write access to the repo to set a state) and to avoid that status checks conflict. For example, if someone experiments with a new tool that would set a markdownlint status, it could approve the PR without the official markdownlint being run. I guess, this also would allow users to circumvent the branch protection rules but they need to have already write access.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Here is the API that allows you to create a status check context with the id: https://docs.github.com/en/graphql/reference/input-objects#requiredstatuscheckinput - the appId field is used to specify a particular action that must already be in the database. Here is the output of searching the network role with the full requiredStatusCheck name + app information:

                createdAt: '2017-03-25T13:51:49Z'
                databaseId: 1861
                description: "This App enforces the [Developer Certificate of Origin](https://developercertificate.org/)\
                    \ (DCO) on Pull Requests. It requires all commit messages to contain\
                ...
                url: https://probot.github.io/apps/dco/
            context: DCO
...
                createdAt: '2020-03-17T21:25:09Z'
                databaseId: 57789
                description: ''
                name: GitHub Code Scanning
                slug: github-code-scanning
                updatedAt: '2021-12-14T16:45:01Z'
                url: https://github.com/features/security
            context: CodeQL
...
                createdAt: '2018-07-30T09:30:17Z'
                databaseId: 15368
                description: Automate your workflow from idea to production
                slug: github-actions
                updatedAt: '2019-12-10T19:04:12Z'
                url: https://help.github.com/en/actions
            context: markdownlint
....

Except for DCO and CodeQL, every "app" is the generic github-actions "app". I guess I could change the input to reflect that. I'm assuming the databaseId field in the output is the appId field I should use in the input - the documentation doesn't really say. It's odd that "real" github actions like ansible_lint do not have their own appId - perhaps because we have custom code in there - for the ansible-lint check we have some custom code to munge meta/main.yml role_name and namespace - for ansible-test we have code to convert to collection.

Note that the key is the context - this must exactly match the name of the job e.g. https://github.com/linux-system-roles/.github/blob/main/playbooks/templates/.github/workflows/ansible-lint.yml#L12

jobs:
  ansible_lint:

so as long as there is only 1 github action which defines jobs.ansible_lint, we can be guaranteed that if we refer to a status check context name as ansible_lint it will use the one defined in .github/workflows/ansible-lint.yml, which requires write access to the repo to change.

Unless you can foresee some attack vector based on status check context naming, I would prefer to use status check context name as is being used currently in this PR.

14 changes: 14 additions & 0 deletions playbooks/graphql_files/create_branch_protection_rules.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
mutation {
createBranchProtectionRule(input:{
{% for key, value in bpr.items() %}
{{ key }}:{{ value | to_nice_json }},
{% endfor %}
repositoryId:"{{ repo.id }}"
}) {
branchProtectionRule {
id
allowsForcePushes
}
clientMutationId
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
mutation {
deleteBranchProtectionRule(input:{
branchProtectionRuleId:"{{ bpr_id }}"
}) {
clientMutationId
}
}
31 changes: 31 additions & 0 deletions playbooks/graphql_files/get_branch_protection_rules.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
query {
repository(owner:"{{ github_org }}", name:"{{ inventory_hostname }}") {
id
branchProtectionRules(first:50) {
edges {
node {
id
{% for param in __bpr_fields %}
{{ param }}
{% endfor %}
{#
requiredStatusChecks {
context
app {
createdAt
databaseId
description
logoBackgroundColor
logoUrl
name
slug
updatedAt
url
}
}
#}
}
}
}
}
}
1 change: 1 addition & 0 deletions playbooks/templates/.github/workflows/ansible-lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ on: # yamllint disable-line rule:truthy
push:
branches:
- main
merge_group:
workflow_dispatch:
permissions:
contents: read
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ on: # yamllint disable-line rule:truthy
push:
branches:
- main
merge_group:
workflow_dispatch:
permissions:
contents: read
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ on: # yamllint disable-line rule:truthy
push:
branches:
- main
merge_group:
workflow_dispatch:
permissions:
contents: read
Expand Down
1 change: 1 addition & 0 deletions playbooks/templates/.github/workflows/ansible-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ on: # yamllint disable-line rule:truthy
push:
branches:
- main
merge_group:
workflow_dispatch:
env:
LSR_ROLE2COLL_NAMESPACE: {{ lsr_namespace }}
Expand Down
1 change: 1 addition & 0 deletions playbooks/templates/.github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ on: # yamllint disable-line rule:truthy
- checks_requested
schedule:
{{ github_actions.codeql.schedule | to_nice_yaml(indent=2) | indent(width=4, first=true) -}}
workflow_dispatch:
jobs:
analyze:
name: Analyze
Expand Down
1 change: 1 addition & 0 deletions playbooks/templates/.github/workflows/markdownlint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ on: # yamllint disable-line rule:truthy
push:
branches:
- main
merge_group:
workflow_dispatch:
permissions:
contents: read
Expand Down
1 change: 1 addition & 0 deletions playbooks/templates/.github/workflows/python-unit-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ on: # yamllint disable-line rule:truthy
push:
branches:
- main
merge_group:
workflow_dispatch:
permissions:
contents: read
Expand Down
112 changes: 112 additions & 0 deletions playbooks/update_branch_protection_rules.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# use the graphql variable names
- name: Manage branch protection rules
hosts: active_roles
connection: local
gather_facts: false
tasks:
- name: Get gh config file
slurp:
path: "{{ lookup('env', 'HOME') ~ '/.config/gh/hosts.yml' }}"
register: gh_cfg_file

- name: Get token
set_fact:
token: "{{ cfg_dict['github.com']['oauth_token'] }}"
vars:
cfg_dict: "{{ gh_cfg_file.content | b64decode | from_yaml }}"

- name: Get Branch Protection Rules
uri:
url: https://api.github.com/graphql
method: POST
headers:
content-type: "application/json"
authorization: "Bearer {{ token }}"
X-Github-Next-Global-ID: "1"
body_format: json
body:
query: "{{ lookup('template', 'graphql_files/get_branch_protection_rules.graphql') }}"
# variables: "{{ lookup('template', './test_vars.yml') | from_yaml | to_nice_json }}"
vars:
__bpr_fields: "{{ (branchProtectionRules | first).keys() | list }}"
register: __query_result

- name: Result
debug:
msg: "{{ __query_result.json.data | to_nice_json }}"
verbosity: 3

- name: Convert result to canonical format
set_fact:
repo: |
{% set __repo = {} %}
{% for key, value in __query_result.json.data.repository.items() %}
{% if key == "branchProtectionRules" %}
{% set _ = __repo.update({key: []})%}
{% for edge in value["edges"] %}
{% if "node" in edge %}
{% set _ = __repo[key].append(edge["node"]) %}
{% endif %}
{% endfor %}
{% else %}
{% set _ = __repo.update({key: value})%}
{% endif %}
{% endfor %}
{{ __repo }}

- name: Show converted result
debug:
msg: repo {{ repo | to_nice_json }} bpr {{ branchProtectionRules | to_nice_json }}
verbosity: 3

- name: Update Branch Protection Rules if needed
vars:
__expected: "{{ branchProtectionRules | sort(attribute='pattern') | map('combine', {'id': ''}) }}"
__actual: "{{ repo.branchProtectionRules | sort(attribute='pattern') | map('combine', {'id': ''}) }}"
when: __expected != __actual
block:
- name: Remove existing rules
uri:
url: https://api.github.com/graphql
method: POST
headers:
content-type: "application/json"
authorization: "Bearer {{ token }}"
X-Github-Next-Global-ID: "1"
body_format: json
body:
query: "{{ lookup('template', 'graphql_files/delete_branch_protection_rules.graphql') }}"
# variables: "{{ lookup('template', './test_vars.yml') | from_yaml | to_nice_json }}"
register: __delete_result
vars:
bpr_id: "{{ item.id }}"
loop: "{{ repo.branchProtectionRules }}"

- name: Show delete result
debug:
msg: __delete_result {{ __delete_result.json.data | to_nice_json }}
verbosity: 3
when: __delete_result.json is defined

- name: Add expected rules
uri:
url: https://api.github.com/graphql
method: POST
headers:
content-type: "application/json"
authorization: "Bearer {{ token }}"
X-Github-Next-Global-ID: "1"
body_format: json
body:
query: "{{ lookup('template', 'graphql_files/create_branch_protection_rules.graphql') }}"
# variables: "{{ lookup('template', './test_vars.yml') | from_yaml | to_nice_json }}"
register: __create_result
loop: "{{ branchProtectionRules }}"
loop_control:
loop_var: bpr

- name: Show create result
debug:
msg: "{{ __create_result.json.data | to_nice_json }}"
verbosity: 3
when: __create_result.json is defined