Skip to content
Merged
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
20 changes: 20 additions & 0 deletions .github/workflows/enforce-policy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
name: Repository policy

on:
pull_request_target:
branches: [main]
types: [opened, edited, reopened, synchronize, ready_for_review]

permissions: {}

concurrency:
group: repository-policy-${{ github.event.pull_request.number }}
cancel-in-progress: true

jobs:
policy:
permissions:
contents: read
pull-requests: read
statuses: write
uses: ./.github/workflows/repository-policy.yml
62 changes: 62 additions & 0 deletions .github/workflows/repository-policy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
name: Repository policy

on:
workflow_call:

permissions: {}

jobs:
validate:
name: Validate repository policy
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
statuses: write
steps:
- name: Mark repository policy as pending
env:
GH_TOKEN: ${{ github.token }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: >-
gh api --method POST
--header "X-GitHub-Api-Version: 2022-11-28"
"repos/${GITHUB_REPOSITORY}/statuses/${HEAD_SHA}"
--field state=pending
--field context=repository-policy
--field description="Validating repository policy"
--field target_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
- name: Check out the immutable policy revision
id: checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
repository: ${{ job.workflow_repository }}
ref: ${{ job.workflow_sha }}
path: .repository-policy
persist-credentials: false
- name: Validate branch, commits, title, and description
id: validate
env:
GH_TOKEN: ${{ github.token }}
run: bash .repository-policy/repository-policy/validate.sh
- name: Finalize repository policy status
if: always()
env:
CHECKOUT_OUTCOME: ${{ steps.checkout.outcome }}
GH_TOKEN: ${{ github.token }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
VALIDATION_OUTCOME: ${{ steps.validate.outcome }}
run: |
state=failure
description="Repository policy failed"
if [[ "$CHECKOUT_OUTCOME" == "success" && "$VALIDATION_OUTCOME" == "success" ]]; then
state=success
description="Repository policy passed"
fi
gh api --method POST \
--header "X-GitHub-Api-Version: 2022-11-28" \
"repos/${GITHUB_REPOSITORY}/statuses/${HEAD_SHA}" \
--field state="$state" \
--field context=repository-policy \
--field description="$description" \
--field target_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
21 changes: 21 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
name: Tests

on:
pull_request:
branches: [main]
push:
branches: [main]

permissions:
contents: read

jobs:
test:
name: ci
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- run: bash -n repository-policy/validate.sh
- run: bash repository-policy/validate.sh --self-test
17 changes: 17 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Contributing

Create changes on a short-lived branch from the latest `main` and open a pull request back into `main`. Direct changes to `main` are not accepted.

Branch names use `<category>/<lowercase-kebab-case-description>`. Allowed categories are `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`, `style`, `test`, and `sandbox`. For example: `feat/contact-import`, `fix/oauth-callback`, or `sandbox/rewe`.

Every commit and pull request title uses Conventional Commit form:

```text
type: concise summary
type(scope): concise summary
type(scope)!: breaking summary
```

Use one of the commit types listed above except `sandbox`, which is branch-only. Keep the header at or below 100 characters, start the summary with a lowercase letter or number, and omit a trailing period. Rebase an unpublished topic branch. When synchronizing a shared topic branch without rewriting its history, merge `main` with the commit subject `chore: synchronize with main`.

Pull request descriptions must retain the required template sections, explain how the change was validated, and identify its impact and rollback. Pull requests authored by someone outside the Code Owners require Code Owner approval. Code Owner-authored pull requests still require the same policy and CI checks before they can be squash-merged into `main`.
19 changes: 19 additions & 0 deletions PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
## Summary

<!-- Briefly explain what this pull request changes. -->

## Context

<!-- Explain why the change is needed and link relevant context. -->

## Validation

<!-- List the commands and manual flows used to verify the change. -->

## Impact and rollback

<!-- Describe user, operational, deployment, or documentation impact and how to reverse the change. Use "Not applicable" only with a short reason. -->

## Screenshots

<!-- Optional. Add screenshots for visible changes or explain why they are not applicable. -->
235 changes: 235 additions & 0 deletions repository-policy/validate.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
#!/usr/bin/env bash
set -euo pipefail

readonly TYPES='build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test'
readonly BRANCH_TYPES="$TYPES|sandbox"
readonly HEADER_MAX_LENGTH=100

errors=()

add_error() {
errors+=("$1")
}

validate_branch() {
local branch="$1"

if [[ "$branch" == dependabot/* ]]; then
return
fi

if [[ ! "$branch" =~ ^($BRANCH_TYPES)/[a-z0-9]+(-[a-z0-9]+)*$ ]]; then
add_error "Branch '$branch' must use <category>/<lowercase-kebab-case-description>."
fi
}

validate_header() {
local label="$1"
local header="$2"

if (( ${#header} > HEADER_MAX_LENGTH )); then
add_error "$label exceeds $HEADER_MAX_LENGTH characters."
fi

if [[ ! "$header" =~ ^($TYPES)(\([a-z0-9]+(-[a-z0-9]+)*\))?(!)?:\ [a-z0-9].+[^.]$ ]]; then
add_error "$label must use type(scope): concise lowercase summary without a trailing period."
fi
}

validate_body() {
local body_file="$1"
local output

if ! output="$(awk '
function strip_comments(text, start, finish, before, after) {
while (1) {
if (in_comment) {
finish = index(text, "-->")
if (!finish) return ""
text = substr(text, finish + 3)
in_comment = 0
}

start = index(text, "<!--")
if (!start) return text

before = substr(text, 1, start - 1)
after = substr(text, start + 4)
finish = index(after, "-->")
if (finish) {
text = before substr(after, finish + 3)
} else {
in_comment = 1
return before
}
}
}

BEGIN {
required[1] = "Summary"
required[2] = "Context"
required[3] = "Validation"
required[4] = "Impact and rollback"
expected = 1
failed = 0
}

{
line = $0
sub(/\r$/, "", line)
line = strip_comments(line)

trimmed = line
sub(/^[[:space:]]+/, "", trimmed)
if (substr(trimmed, 1, 3) == "```" || substr(trimmed, 1, 3) == "~~~") {
in_fence = !in_fence
next
}
if (in_fence) next

if (line ~ /^##[[:space:]]+/) {
heading = line
sub(/^##[[:space:]]+/, "", heading)
sub(/[[:space:]]+$/, "", heading)
current = heading

for (i = 1; i <= 4; i++) {
if (heading == required[i]) {
count[i]++
if (i != expected) {
print "Required section is out of order: ## " heading
failed = 1
} else {
expected++
}
}
}
next
}

for (i = 1; i <= 4; i++) {
if (current == required[i]) {
visible = line
gsub(/<[^>]*>/, "", visible)
gsub(/[[:space:][:punct:]]/, "", visible)
if (length(visible) >= 3) filled[i] = 1
}
}
}

END {
for (i = 1; i <= 4; i++) {
if (count[i] == 0) {
print "Missing required section: ## " required[i]
failed = 1
} else if (count[i] > 1) {
print "Duplicate required section: ## " required[i]
failed = 1
} else if (!filled[i]) {
print "Required section is empty: ## " required[i]
failed = 1
}
}
exit failed
}
' "$body_file" 2>&1)"; then
while IFS= read -r line; do
[[ -n "$line" ]] && add_error "$line"
done <<< "$output"
fi
}

self_test() {
local temporary_directory
temporary_directory="$(mktemp -d)"
trap 'rm -rf "$temporary_directory"' RETURN

errors=()
validate_branch "feat/contact-import"
validate_branch "sandbox/rewe"
validate_branch "dependabot/npm_and_yarn/zod-4.0.0"
[[ ${#errors[@]} -eq 0 ]]

errors=()
validate_branch "feature/contact-import"
validate_branch "feat/Contact_import"
[[ ${#errors[@]} -eq 2 ]]

errors=()
validate_header "Header" "feat(contacts): add contact import"
validate_header "Header" "fix!: prevent duplicate messages"
validate_header "Header" "chore(release): 1.4.0"
[[ ${#errors[@]} -eq 0 ]]

errors=()
validate_header "Header" "Feature: Add contact import"
validate_header "Header" "feat: add contact import."
validate_header "Header" "feat(Bad Scope): add contact import"
[[ ${#errors[@]} -eq 3 ]]

printf '%s\n' \
'## Summary' 'Adds contact import.' '' \
'## Context' 'Customers need a faster import.' '' \
'## Validation' 'Ran the unit tests.' '' \
'## Impact and rollback' 'Revert the pull request.' > "$temporary_directory/valid-body"
errors=()
validate_body "$temporary_directory/valid-body"
[[ ${#errors[@]} -eq 0 ]]

printf '%s\n' \
'## Context' 'Out of order.' '' \
'## Summary' '<!-- empty -->' '' \
'## Validation' 'Ran tests.' '' \
'## Validation' 'Duplicate.' > "$temporary_directory/invalid-body"
errors=()
validate_body "$temporary_directory/invalid-body"
[[ ${#errors[@]} -ge 4 ]]

printf '%s\n' "Repository policy self-test passed."
}

if [[ "${1:-}" == "--self-test" ]]; then
self_test
exit
fi

: "${GH_TOKEN:?GH_TOKEN is required}"
: "${GITHUB_EVENT_PATH:?GITHUB_EVENT_PATH is required}"
: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}"

readonly pr_number="$(jq -r '.pull_request.number // empty' "$GITHUB_EVENT_PATH")"
readonly head_sha="$(jq -r '.pull_request.head.sha // empty' "$GITHUB_EVENT_PATH")"
readonly head_branch="$(jq -r '.pull_request.head.ref // empty' "$GITHUB_EVENT_PATH")"
readonly title="$(jq -r '.pull_request.title // ""' "$GITHUB_EVENT_PATH")"

: "${pr_number:?Pull request number is missing from the event}"
: "${head_sha:?Pull request head SHA is missing from the event}"
: "${head_branch:?Pull request head branch is missing from the event}"

validate_branch "$head_branch"

validate_header "Pull request title" "$title"

body_file="$(mktemp)"
commits_file="$(mktemp)"
trap 'rm -f "$body_file" "$commits_file"' EXIT
jq -r '.pull_request.body // ""' "$GITHUB_EVENT_PATH" > "$body_file"
validate_body "$body_file"

gh api --paginate \
--header "X-GitHub-Api-Version: 2022-11-28" \
"repos/${GITHUB_REPOSITORY}/pulls/${pr_number}/commits?per_page=100" \
--jq '.[] | [.sha, (.commit.message | split("\n")[0])] | @tsv' > "$commits_file"

while IFS=$'\t' read -r commit_sha commit_header; do
[[ -n "$commit_sha" ]] || continue
validate_header "Commit ${commit_sha:0:12}" "$commit_header"
done < "$commits_file"

if (( ${#errors[@]} > 0 )); then
printf '%s\n' "Repository policy failed:" >&2
printf ' - %s\n' "${errors[@]}" >&2
exit 1
fi

printf '%s\n' "Repository policy passed"