diff --git a/.github/workflows/enforce-policy.yml b/.github/workflows/enforce-policy.yml new file mode 100644 index 0000000..ef3adaf --- /dev/null +++ b/.github/workflows/enforce-policy.yml @@ -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 diff --git a/.github/workflows/repository-policy.yml b/.github/workflows/repository-policy.yml new file mode 100644 index 0000000..da91723 --- /dev/null +++ b/.github/workflows/repository-policy.yml @@ -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}" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..5ad22c1 --- /dev/null +++ b/.github/workflows/test.yml @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..d465f4f --- /dev/null +++ b/CONTRIBUTING.md @@ -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 `/`. 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`. diff --git a/PULL_REQUEST_TEMPLATE.md b/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..7d81b70 --- /dev/null +++ b/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,19 @@ +## Summary + + + +## Context + + + +## Validation + + + +## Impact and rollback + + + +## Screenshots + + diff --git a/repository-policy/validate.sh b/repository-policy/validate.sh new file mode 100755 index 0000000..1546247 --- /dev/null +++ b/repository-policy/validate.sh @@ -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 /." + 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 (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' '' '' \ + '## 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"