
Github Project
- 84 installs
- 8 repo stars
- Updated August 4, 2026
- netresearch/github-project-skill
Helps with ai & agent building tasks.
About
github-project is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- github-project
- AI & Agent Building
- AI-coding skill
Github Project by the numbers
- 84 all-time installs (skills.sh)
- +10 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #5,107 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/netresearch/github-project-skill --skill github-projectAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 84 |
|---|---|
| repo stars | ★ 8 |
| Last updated | August 4, 2026 |
| Repository | netresearch/github-project-skill ↗ |
What it does
Helps with ai & agent building tasks.
Files
GitHub Project Skill
GitHub repository configuration, troubleshooting, and collaboration workflow best practices.
When to Use
- Post `gh repo create` + initial push, before first PR — apply branch protection (REQUIRED, see below)
- PR won't merge, BLOCKED, or unresolved threads
- Auto-merge fails for Dependabot/Renovate
- Solo maintainer auto-approve
- Branch protection, rulesets,
enforce_admins - GHA failures or permission issues
- Signed commit merge (rebase can't auto-sign)
- CodeQL default vs custom workflows
- Scorecard (token perms, pinned deps)
- CODEOWNERS, templates, release labels
- Fork PR merge base
REQUIRED post `gh repo create`:scripts/init-branch-protection.sh OWNER/REPO— seereferences/repo-bootstrap.md(closes snipe-it#17 class).
Quick Diagnostics
PR Won't Merge
gh pr view PR --repo OWNER/REPO \
--json mergeStateStatus,reviewDecision,mergeable,reviewThreadsSolo Maintainer: PRs Stuck on REVIEW_REQUIRED
Use assets/pr-quality.yml.template for auto-approve with required_approving_review_count >= 1.
Auto-merge Setup
Requires allow_auto_merge, pull_request_target trigger, user.login bot detection, gh pr merge --auto with dynamic strategy. See references/auto-merge-guide.md.
Auto-merge Not Working
gh pr view PR --repo OWNER/REPO --json autoMergeRequest --jq .autoMergeRequest
gh api repos/OWNER/REPO/branches/main/protection/required_pull_request_reviews \
--jq '.bypass_pull_request_allowances.apps[].slug'GitHub Actions Failing
gh run list --repo OWNER/REPO --limit 5
gh run view RUN_ID --repo OWNER/REPO --log-failed
gh run rerun RUN_ID --repo OWNER/REPOSecurity & Compliance Quick Checks
gh api repos/OWNER/REPO/branches/main/protection \
--jq '{rcr: .required_conversation_resolution.enabled, admins: .enforce_admins.enabled}'
gh api repos/OWNER/REPO/code-scanning/default-setup --jq '.state'
gh pr view PR --repo OWNER/REPO --json reviewThreads --jq '.reviewThreads'Merge Strategy Issues
See references/auto-merge-guide.md (signed-commit rebase fixes, workflow-file PRs, Copilot auto-approve race).
Running Scripts
scripts/init-branch-protection.sh OWNER/REPO # baseline (post gh repo create)
scripts/init-branch-protection.sh OWNER/REPO --from-current-checks # after first CI
scripts/verify-github-project.sh /path/to/repository # local-checkout auditReferences
| Topic | Reference |
|---|---|
Repo bootstrap (post gh repo create) | references/repo-bootstrap.md |
| Repository file layout | references/repository-structure.md |
| Branch migration | references/branch-migration.md |
| Dependabot/Renovate | references/dependency-management.md |
| Auto-approve + auto-merge | references/auto-merge-guide.md |
| Merge strategy (signed commits) | references/merge-strategy.md |
| Sub-issues | references/sub-issues.md |
| Release labeling | references/release-labeling.md |
| gh CLI commands | references/gh-cli-reference.md |
| Polyglot CI checklists | references/repo-setup-guide.md |
| Scorecard, CodeQL, security | references/security-config.md |
| actionlint | references/actionlint-guide.md |
| Workflow bash pitfalls | references/workflow-bash-patterns.md |
| Fork merge base | references/pr-commit-cleanup.md |
| Multi-repo batch ops | references/multi-repo-operations.md |
| Reusable workflow security | references/reusable-workflow-security.md |
| Reusable workflow pitfalls | references/reusable-workflow-pitfalls.md |
| Org security settings | references/org-security-settings.md |
| Tag validation | references/tag-validation.md |
| AI reviewer pushback | references/ai-reviewer-pushback.md |
| Agentic workflows | references/agentic-workflows.md |
---
Contributing: https://github.com/netresearch/github-project-skill
# .github/workflows/auto-merge-deps.yml
# Auto-merge dependency updates for repositories WITHOUT branch protection
# Use this template when: Repository has no branch protection rules
# Note: --auto flag requires branch protection; this template uses direct merge
# Note: PRs that modify workflow files require manual merge (GITHUB_TOKEN limitation)
# Merge strategy is auto-detected from repo settings (squash > merge > rebase).
name: Auto-merge dependency PRs
on:
pull_request_target:
types: [opened, synchronize, reopened]
permissions:
contents: write
pull-requests: write
jobs:
auto-merge:
name: Auto-merge dependency PRs
runs-on: ubuntu-latest
if: github.event.pull_request.user.login == 'dependabot[bot]' || github.event.pull_request.user.login == 'renovate[bot]'
steps:
- name: Harden Runner
uses: step-security/harden-runner@v2
with:
egress-policy: audit
- name: Dependabot metadata
id: metadata
if: github.event.pull_request.user.login == 'dependabot[bot]'
uses: dependabot/fetch-metadata@v2
with:
github-token: "${{ secrets.GITHUB_TOKEN }}"
- name: Auto-approve PR
run: gh pr review --approve "$PR_URL"
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Wait for CI checks
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
echo "Waiting for CI checks to pass..."
sleep 30
# Poll checks excluding this workflow to avoid deadlock
for i in $(seq 1 90); do
CHECKS=$(gh pr checks "$PR_URL" --json name,state,conclusion 2>/dev/null || echo "[]")
# Filter out auto-merge workflow and check states
PENDING=$(echo "$CHECKS" | jq -r '.[] | select(.name != "Auto-merge dependency PRs" and .name != "auto-merge") | select(.state == "PENDING" or .conclusion == null) | .name' | head -5)
FAILED=$(echo "$CHECKS" | jq -r '.[] | select(.name != "Auto-merge dependency PRs" and .name != "auto-merge") | select(.conclusion == "FAILURE" or .conclusion == "CANCELLED") | .name' | head -5)
if [ -n "$FAILED" ]; then
echo "CI checks failed: $FAILED"
exit 1
fi
if [ -z "$PENDING" ]; then
echo "All CI checks passed!"
exit 0
fi
echo "Waiting for: $PENDING"
sleep 10
done
echo "Timeout waiting for CI checks"
exit 1
- name: Check for workflow file changes
id: check-workflows
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
WORKFLOW_FILES=$(gh pr diff "$PR_URL" --name-only | grep -E '^\.github/workflows/' || true)
if [ -n "$WORKFLOW_FILES" ]; then
echo "modifies_workflows=true" >> "$GITHUB_OUTPUT"
echo "⚠️ PR modifies workflow files - requires manual merge (GITHUB_TOKEN lacks workflows permission)"
echo "Modified workflow files:"
echo "$WORKFLOW_FILES"
else
echo "modifies_workflows=false" >> "$GITHUB_OUTPUT"
fi
- name: Merge PR
if: steps.check-workflows.outputs.modifies_workflows != 'true'
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Detect allowed merge strategy
# Prefer squash (works with signed commit requirements, clean for single-commit PRs)
# then merge (also works with signed commits), then rebase (cannot be auto-signed)
STRATEGY=$(gh api "repos/${{ github.repository }}" --jq '
if .allow_squash_merge then "--squash"
elif .allow_merge_commit then "--merge"
elif .allow_rebase_merge then "--rebase"
else "--squash" end')
echo "Using merge strategy: $STRATEGY"
gh pr merge $STRATEGY "$PR_URL"
- name: Comment on workflow PR
if: steps.check-workflows.outputs.modifies_workflows == 'true'
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh pr comment "$PR_URL" --body "✅ CI checks passed and PR approved. This PR modifies workflow files and requires manual merge (GITHUB_TOKEN cannot merge workflow changes)."
# .github/workflows/auto-merge-deps.yml
# Auto-merge dependency updates for repositories with MERGE QUEUE enabled
# Use this template when: Repository has merge queue configured
# Note: mergeMethod is NOT a valid parameter for enqueuePullRequest - merge method is set by queue config
name: Auto-merge dependency PRs
on:
pull_request_target:
types: [opened, synchronize, reopened]
permissions:
contents: write
pull-requests: write
jobs:
auto-merge:
name: Auto-merge dependency PRs
runs-on: ubuntu-latest
# Use github.event.pull_request.user.login instead of github.actor
# because actor can change on synchronize/rerun events
if: >-
github.event.pull_request.user.login == 'dependabot[bot]' ||
github.event.pull_request.user.login == 'renovate[bot]'
steps:
- name: Harden Runner
uses: step-security/harden-runner@v2
with:
egress-policy: audit
- name: Dependabot metadata
id: metadata
if: github.event.pull_request.user.login == 'dependabot[bot]'
uses: dependabot/fetch-metadata@v2
with:
github-token: "${{ secrets.GITHUB_TOKEN }}"
- name: Auto-approve PR
run: gh pr review --approve "$PR_URL"
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Wait for required status checks
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
echo "Waiting for all required status checks to pass..."
# Wait up to 30 minutes for checks to complete
timeout 1800 gh pr checks "$PR_URL" --watch --fail-fast || {
echo "Status checks did not pass within timeout"
exit 1
}
echo "All status checks passed!"
- name: Add to merge queue
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NODE_ID: ${{ github.event.pull_request.node_id }}
run: |
gh api graphql -f query='
mutation($pullRequestId: ID!) {
enqueuePullRequest(input: {pullRequestId: $pullRequestId}) {
mergeQueueEntry { id }
}
}' -f pullRequestId="$PR_NODE_ID" || {
echo "Failed to enqueue PR in merge queue"
exit 1
}
echo "PR successfully added to merge queue!"
# .github/workflows/auto-merge-deps.yml
# Auto-merge dependency updates from Dependabot and Renovate
# Requires: branch protection enabled (--auto flag needs it)
# Merge strategy is auto-detected from repo settings (squash > merge > rebase)
name: Auto-merge dependency PRs
on:
pull_request_target:
types: [opened, synchronize, reopened]
permissions:
contents: read
jobs:
auto-merge:
name: Auto-merge dependency PRs
runs-on: ubuntu-latest
# Use github.event.pull_request.user.login instead of github.actor
# because actor can change on synchronize/rerun events
if: >-
github.event.pull_request.user.login == 'dependabot[bot]' ||
github.event.pull_request.user.login == 'renovate[bot]'
permissions:
contents: write
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@v2
with:
egress-policy: audit
- name: Approve PR
run: gh pr review --approve "$PR_URL"
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Enable auto-merge
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Detect allowed merge strategy
# Prefer squash (works with signed commit requirements, clean for single-commit PRs)
# then merge (also works with signed commits), then rebase (cannot be auto-signed)
STRATEGY=$(gh api "repos/${{ github.repository }}" --jq '
if .allow_squash_merge then "--squash"
elif .allow_merge_commit then "--merge"
elif .allow_rebase_merge then "--rebase"
else "--squash" end')
echo "Using merge strategy: $STRATEGY"
gh pr merge --auto $STRATEGY "$PR_URL"
{
"required_status_checks": null,
"enforce_admins": false,
"required_pull_request_reviews": {
"required_approving_review_count": 1,
"dismiss_stale_reviews": false,
"require_code_owner_reviews": false,
"require_last_push_approval": false
},
"restrictions": null,
"required_linear_history": false,
"allow_force_pushes": false,
"allow_deletions": false,
"required_conversation_resolution": true,
"lock_branch": false,
"allow_fork_syncing": false
}
---
name: Bug Report
about: Report a bug to help us improve
title: '[BUG] '
labels: bug
assignees: ''
---
## Description
A clear and concise description of the bug.
## Steps to Reproduce
1. ...
2. ...
3. ...
## Expected Behavior
What you expected to happen.
## Actual Behavior
What actually happened.
## Environment
- **Version**:
- **OS**:
- **Go/PHP/Node version**:
- **Other relevant info**:
## Logs / Error Messages
```
Paste any relevant logs or error messages here
```
## Screenshots
If applicable, add screenshots to help explain the problem.
## Additional Context
Any other relevant information about the problem.
## Possible Solution
If you have suggestions for fixing the bug, describe them here.
# .github/CODEOWNERS
# Code ownership for automatic review assignment
# Documentation: https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
# Default owners for everything in the repo
* @{{ORG}}/maintainers
# Core source code requires team review
/src/ @{{ORG}}/core-team
/Classes/ @{{ORG}}/core-team
/internal/ @{{ORG}}/core-team
/pkg/ @{{ORG}}/core-team
# CI/CD and security-sensitive files require maintainer approval
/.github/ @{{ORG}}/maintainers
/.github/workflows/ @{{ORG}}/security-team
# Security policy requires security team approval
/SECURITY.md @{{ORG}}/security-team
# Documentation can be reviewed by docs team
/docs/ @{{ORG}}/docs-team
/Documentation/ @{{ORG}}/docs-team
*.md @{{ORG}}/docs-team
# Tests can be reviewed by QA team
/tests/ @{{ORG}}/qa-team
/Tests/ @{{ORG}}/qa-team
*_test.go @{{ORG}}/qa-team
*Test.php @{{ORG}}/qa-team
# Contributing to {{PROJECT}}
Thank you for your interest in contributing! This document provides guidelines and information for contributors.
## Code of Conduct
This project follows the [Contributor Covenant](CODE_OF_CONDUCT.md). By participating, you agree to uphold this code.
## Getting Started
### Prerequisites
- {{LANGUAGE}} {{VERSION}}+
- Git
### Development Setup
```bash
# Clone the repository
git clone https://github.com/{{ORG}}/{{REPO}}.git
cd {{REPO}}
# Install dependencies
{{INSTALL_COMMAND}}
# Run tests
{{TEST_COMMAND}}
```
## How to Contribute
### Reporting Bugs
1. Check existing [issues](https://github.com/{{ORG}}/{{REPO}}/issues) first
2. Use the [bug report template](.github/ISSUE_TEMPLATE/bug_report.md)
3. Include:
- Clear description of the problem
- Steps to reproduce
- Expected vs actual behavior
- Version information
### Suggesting Features
1. Check existing [feature requests](https://github.com/{{ORG}}/{{REPO}}/issues?q=label:enhancement)
2. Use the [feature request template](.github/ISSUE_TEMPLATE/feature_request.md)
3. Describe the use case and proposed solution
### Submitting Code
1. Fork the repository
2. Create a feature branch: `git checkout -b feature/my-feature`
3. Make your changes
4. Write/update tests
5. Ensure all tests pass: `{{TEST_COMMAND}}`
6. Commit using [Conventional Commits](#commit-messages)
7. Push and create a Pull Request
## Development Guidelines
### Code Style
{{CODE_STYLE_INSTRUCTIONS}}
Run code formatting before committing:
```bash
{{FORMAT_COMMAND}}
```
### Testing
We use a multi-layer testing strategy:
- **Unit Tests**: Test individual functions/methods
- **Integration Tests**: Test component interactions
- **End-to-End Tests**: Test complete workflows
Run all tests:
```bash
{{TEST_COMMAND}}
```
### Commit Messages
We follow [Conventional Commits](https://www.conventionalcommits.org/):
```
<type>[optional scope]: <description>
[optional body]
[optional footer(s)]
```
**Types:**
- `feat`: New feature
- `fix`: Bug fix
- `docs`: Documentation only
- `style`: Code style (formatting, no logic change)
- `refactor`: Code refactoring
- `perf`: Performance improvements
- `test`: Adding/updating tests
- `build`: Build system changes
- `ci`: CI configuration changes
- `chore`: Maintenance tasks
**Examples:**
```
feat: add user authentication
fix(api): resolve timeout on large requests
docs: update installation instructions
```
### Pull Request Process
1. Update documentation if needed
2. Add tests for new functionality
3. Ensure CI passes
4. Request review from maintainers
5. Address review feedback
6. Squash commits if requested
### Pull Request Checklist
- [ ] Code follows project style guidelines
- [ ] Tests added/updated and passing
- [ ] Documentation updated
- [ ] Commit messages follow Conventional Commits
- [ ] No unrelated changes included
## Release Process
Releases are automated via GitHub Actions when tags are pushed:
```bash
git tag v1.0.0
git push origin v1.0.0
```
## Getting Help
- Open a [GitHub Discussion](https://github.com/{{ORG}}/{{REPO}}/discussions)
- Check existing documentation
- Review closed issues for similar problems
## License
By contributing, you agree that your contributions will be licensed under the project's [license](LICENSE).
# .github/dependabot.yml
# Automated dependency updates
# Documentation: https://docs.github.com/en/code-security/dependabot/dependabot-version-updates
version: 2
updates:
# {{ECOSYSTEM}} dependencies
- package-ecosystem: "{{ECOSYSTEM}}"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "06:00"
timezone: "Europe/Berlin"
groups:
dependencies:
patterns:
- "*"
commit-message:
prefix: "deps"
labels:
- "dependencies"
open-pull-requests-limit: 10
# GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "06:00"
timezone: "Europe/Berlin"
groups:
github-actions:
patterns:
- "*"
commit-message:
prefix: "ci"
labels:
- "dependencies"
- "github-actions"
# Docker (if applicable)
# - package-ecosystem: "docker"
# directory: "/"
# schedule:
# interval: "weekly"
# commit-message:
# prefix: "docker"
# labels:
# - "dependencies"
# - "docker"
---
name: Feature Request
about: Suggest an enhancement or new feature
title: '[FEATURE] '
labels: enhancement
assignees: ''
---
## Problem Statement
A clear description of the problem or limitation you're experiencing.
**Is this related to an existing issue?**
Link to any related issues.
## Proposed Solution
Describe the feature or enhancement you'd like to see.
### Implementation Ideas
If you have thoughts on how this could be implemented, share them here.
## Alternatives Considered
Describe any alternative solutions or workarounds you've considered.
## Use Case
Describe the use case for this feature. Who would benefit and how?
**Example scenario:**
```
As a [type of user], I want to [do something] so that [benefit].
```
## Additional Context
- Screenshots, mockups, or examples from other projects
- Links to relevant documentation or discussions
- Any other context that helps explain the request
## Acceptance Criteria
- [ ] Criterion 1
- [ ] Criterion 2
- [ ] Criterion 3
## Priority
How important is this feature to you?
- [ ] Critical - blocking my work
- [ ] High - would significantly improve my workflow
- [ ] Medium - nice to have
- [ ] Low - minor improvement
# .github/workflows/pr-quality.yml
# Solo-maintainer auto-approve: approves PRs from repo collaborators
# so that required_approving_review_count >= 1 is satisfied without
# manual review for trusted authors.
#
# SECURITY: This workflow uses pull_request_target which runs with base branch
# permissions. NEVER add an actions/checkout step here -- that would allow code
# from the PR to execute with write access to the repository.
#
# Bootstrap: The PR that introduces this workflow must be approved manually
# (the workflow isn't on the base branch yet). All subsequent PRs auto-approve.
name: PR Quality Gates
on:
pull_request_target:
types: [opened, synchronize, reopened]
jobs:
auto-approve:
name: Auto-approve (collaborators)
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@v2
with:
egress-policy: audit
- name: Check author permission
id: check-permission
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
REPO: ${{ github.repository }}
run: |
PERMISSION=$(gh api "repos/$REPO/collaborators/$PR_AUTHOR/permission" --jq '.permission' 2>/dev/null || echo "none")
echo "permission=$PERMISSION" >> "$GITHUB_OUTPUT"
- name: Auto-approve PR
if: steps.check-permission.outputs.permission == 'admin' || steps.check-permission.outputs.permission == 'write'
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh pr review --approve "$PR_URL"
## Description
Brief description of the changes in this PR.
## Type of Change
- [ ] Bug fix (non-breaking change fixing an issue)
- [ ] New feature (non-breaking change adding functionality)
- [ ] Breaking change (fix or feature causing existing functionality to change)
- [ ] Documentation update
- [ ] Refactoring (no functional changes)
- [ ] Performance improvement
- [ ] Test update
## Related Issues
Fixes #(issue number)
Related to #(issue number)
## Changes Made
- Change 1
- Change 2
- Change 3
## Testing
### How to Test
1. Step 1
2. Step 2
3. Step 3
### Test Results
- [ ] Unit tests pass
- [ ] Integration tests pass
- [ ] Manual testing completed
## Checklist
- [ ] My code follows the project's coding standards
- [ ] I have run linting/formatting and all checks pass
- [ ] I have added tests covering my changes
- [ ] I have updated documentation as needed
- [ ] My commits follow the conventional commit format
- [ ] I have rebased on the latest main branch
- [ ] Breaking changes are documented
## Screenshots (if applicable)
| Before | After |
|--------|-------|
| | |
## Additional Notes
Any additional information reviewers should know.
## Deployment Notes
Any special considerations for deploying this change.
# Release Labeler Workflow
# Automatically labels PRs and issues with the release version they shipped in
# and creates an announcement discussion for each release.
#
# Features:
# - Creates a discussion in Announcements category for each release
# - Creates `released:vX.Y.Z` label on release publish
# - Labels all PRs merged between previous and current release
# - Labels issues that were closed by those PRs
# - Adds comment linking to the release
#
# Usage:
# 1. Copy to .github/workflows/release-labeler.yml
# 2. Ensure GITHUB_TOKEN has issues:write, pull-requests:write, and discussions:write permissions
# 3. Enable Discussions on the repository (Settings > General > Features > Discussions)
name: Release Labeler
on:
release:
types: [published]
permissions:
contents: read
jobs:
announce-release:
name: Create Discussion Announcement
runs-on: ubuntu-latest
permissions:
contents: read
discussions: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
with:
egress-policy: audit
- name: Resolve announcements category ID
id: category
env:
GH_TOKEN: ${{ github.token }}
REPO_OWNER: ${{ github.repository_owner }}
REPO_NAME: ${{ github.event.repository.name }}
run: |
CATEGORY_ID=$(gh api graphql -f query='
query($owner: String!, $name: String!) {
repository(owner: $owner, name: $name) {
discussionCategories(first: 20) {
nodes { id name }
}
}
}' -f owner="$REPO_OWNER" -f name="$REPO_NAME" \
--jq '.data.repository.discussionCategories.nodes[] | select(.name == "Announcements") | .id')
if [[ -z "$CATEGORY_ID" ]]; then
echo "::warning::No 'Announcements' discussion category found, skipping"
echo "found=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "id=$CATEGORY_ID" >> "$GITHUB_OUTPUT"
echo "found=true" >> "$GITHUB_OUTPUT"
- name: Create announcement discussion
if: steps.category.outputs.found == 'true'
env:
GH_TOKEN: ${{ github.token }}
REPO_OWNER: ${{ github.repository_owner }}
REPO_NAME: ${{ github.event.repository.name }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
RELEASE_URL: ${{ github.event.release.html_url }}
RELEASE_BODY: ${{ github.event.release.body }}
REPO_ID: ${{ github.event.repository.node_id }}
CATEGORY_ID: ${{ steps.category.outputs.id }}
run: |
# Build discussion body safely (no shell expansion of release body)
{
printf '## [%s](%s)\n\n' "$RELEASE_TAG" "$RELEASE_URL"
printf '%s\n\n' "$RELEASE_BODY"
printf '---\n*Automatically created from [GitHub Release](%s).*\n' "$RELEASE_URL"
} > /tmp/discussion-body.md
# Check if discussion already exists (search all announcements by title)
EXISTING=$(gh api graphql -f query='
query($owner: String!, $name: String!, $categoryId: ID!) {
repository(owner: $owner, name: $name) {
discussions(first: 100, categoryId: $categoryId, orderBy: {field: CREATED_AT, direction: DESC}) {
nodes { title }
}
}
}' \
-f owner="$REPO_OWNER" \
-f name="$REPO_NAME" \
-f categoryId="$CATEGORY_ID" \
--jq '.data.repository.discussions.nodes[].title' | grep -Fx -- "$RELEASE_TAG" || true)
if [[ -n "$EXISTING" ]]; then
echo "Discussion for $RELEASE_TAG already exists, skipping"
exit 0
fi
gh api graphql \
-f query='mutation($repoId: ID!, $categoryId: ID!, $title: String!, $body: String!) {
createDiscussion(input: {repositoryId: $repoId, categoryId: $categoryId, title: $title, body: $body}) {
discussion { url }
}
}' \
-f repoId="$REPO_ID" \
-f categoryId="$CATEGORY_ID" \
-f title="$RELEASE_TAG" \
-F body=@/tmp/discussion-body.md
echo "## Announcement Created" >> $GITHUB_STEP_SUMMARY
echo "Discussion created for $RELEASE_TAG" >> $GITHUB_STEP_SUMMARY
label-release:
name: Label PRs and Issues
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2
with:
egress-policy: audit
- name: Get release info
id: release
env:
RELEASE_TAG: ${{ github.event.release.tag_name }}
RELEASE_URL: ${{ github.event.release.html_url }}
run: |
echo "tag=${RELEASE_TAG}" >> $GITHUB_OUTPUT
echo "url=${RELEASE_URL}" >> $GITHUB_OUTPUT
# Label format: released:v1.2.3
echo "label=released:${RELEASE_TAG}" >> $GITHUB_OUTPUT
- name: Get previous release tag
id: prev_release
env:
GH_TOKEN: ${{ github.token }}
CURRENT_TAG: ${{ steps.release.outputs.tag }}
run: |
# Get the previous release tag (skip current)
PREV_TAG=$(gh api repos/${{ github.repository }}/releases \
--jq "[.[] | select(.tag_name != \"${CURRENT_TAG}\" and .prerelease == false)] | .[0].tag_name // \"\"")
echo "tag=${PREV_TAG}" >> $GITHUB_OUTPUT
echo "Previous release: ${PREV_TAG:-none}"
- name: Create release label
env:
GH_TOKEN: ${{ github.token }}
LABEL_NAME: ${{ steps.release.outputs.label }}
RELEASE_TAG: ${{ steps.release.outputs.tag }}
run: |
# Create or update label (green color: 0e8a16)
gh label create "${LABEL_NAME}" \
--repo ${{ github.repository }} \
--color 0e8a16 \
--description "Released in ${RELEASE_TAG}" \
--force
- name: Find and label merged PRs
id: label_prs
env:
GH_TOKEN: ${{ github.token }}
LABEL_NAME: ${{ steps.release.outputs.label }}
RELEASE_URL: ${{ steps.release.outputs.url }}
RELEASE_TAG: ${{ steps.release.outputs.tag }}
PREV_TAG: ${{ steps.prev_release.outputs.tag }}
run: |
LABELED_PRS=""
# Get merge commits between tags
if [[ -n "${PREV_TAG}" ]]; then
echo "Finding PRs merged between ${PREV_TAG} and ${RELEASE_TAG}"
# Get commits between tags with explicit error handling
if ! COMMITS=$(gh api repos/${{ github.repository }}/compare/${PREV_TAG}...${RELEASE_TAG} \
--jq '.commits[].sha' 2>&1); then
echo "::warning::Failed to fetch commits between ${PREV_TAG} and ${RELEASE_TAG}: ${COMMITS}"
COMMITS=""
fi
else
echo "No previous release found"
COMMITS=""
fi
# Find merged PRs
if [[ -n "${COMMITS}" ]]; then
# Search for PRs by merge commit
for sha in ${COMMITS}; do
PR_NUM=$(gh api repos/${{ github.repository }}/commits/${sha}/pulls \
--jq '.[0].number // empty' 2>/dev/null || echo "")
if [[ -n "${PR_NUM}" ]]; then
echo "Found PR #${PR_NUM} for commit ${sha:0:7}"
LABELED_PRS="${LABELED_PRS} ${PR_NUM}"
# Add label
gh pr edit ${PR_NUM} --repo ${{ github.repository }} \
--add-label "${LABEL_NAME}" 2>/dev/null || true
# Add comment (skip if already commented)
EXISTING=$(gh pr view ${PR_NUM} --repo ${{ github.repository }} \
--json comments --jq ".comments[].body | select(contains(\"${RELEASE_TAG}\"))" 2>/dev/null || echo "")
if [[ -z "${EXISTING}" ]]; then
gh pr comment ${PR_NUM} --repo ${{ github.repository }} \
--body "Released in [${RELEASE_TAG}](${RELEASE_URL})" 2>/dev/null || true
fi
fi
done
else
# Fallback for first release: find PRs merged to default branch
# Only label PRs that don't already have a released: label
echo "Using fallback: searching recently merged PRs without release labels"
PR_LIST=$(gh pr list --repo ${{ github.repository }} --state merged --limit 50 \
--json number,labels --jq '.[] | select(.labels | map(.name) | any(startswith("released:")) | not) | .number')
for PR_NUM in ${PR_LIST}; do
echo "Labeling PR #${PR_NUM} (first release)"
LABELED_PRS="${LABELED_PRS} ${PR_NUM}"
gh pr edit ${PR_NUM} --repo ${{ github.repository }} \
--add-label "${LABEL_NAME}" 2>/dev/null || true
done
fi
# Output labeled PRs for next step (avoid race condition with label query)
echo "labeled_prs=${LABELED_PRS}" >> $GITHUB_OUTPUT
- name: Find and label closed issues
env:
GH_TOKEN: ${{ github.token }}
LABEL_NAME: ${{ steps.release.outputs.label }}
RELEASE_URL: ${{ steps.release.outputs.url }}
RELEASE_TAG: ${{ steps.release.outputs.tag }}
LABELED_PRS: ${{ steps.label_prs.outputs.labeled_prs }}
run: |
# Use PR numbers from previous step (avoids race condition with label propagation)
for PR_NUM in ${LABELED_PRS}; do
# Get issues linked/closed by this PR
LINKED_ISSUES=$(gh pr view ${PR_NUM} --repo ${{ github.repository }} \
--json closingIssuesReferences --jq '.closingIssuesReferences[].number' 2>/dev/null || echo "")
for ISSUE_NUM in ${LINKED_ISSUES}; do
echo "Labeling issue #${ISSUE_NUM} (closed by PR #${PR_NUM})"
gh issue edit ${ISSUE_NUM} --repo ${{ github.repository }} \
--add-label "${LABEL_NAME}" 2>/dev/null || true
# Add comment
EXISTING=$(gh issue view ${ISSUE_NUM} --repo ${{ github.repository }} \
--json comments --jq ".comments[].body | select(contains(\"${RELEASE_TAG}\"))" 2>/dev/null || echo "")
if [[ -z "${EXISTING}" ]]; then
gh issue comment ${ISSUE_NUM} --repo ${{ github.repository }} \
--body "Fixed in [${RELEASE_TAG}](${RELEASE_URL})" 2>/dev/null || true
fi
done
done
- name: Summary
env:
GH_TOKEN: ${{ github.token }}
LABEL_NAME: ${{ steps.release.outputs.label }}
run: |
PR_COUNT=$(gh pr list --repo ${{ github.repository }} --state merged \
--label "${LABEL_NAME}" --json number --jq 'length')
ISSUE_COUNT=$(gh issue list --repo ${{ github.repository }} --state closed \
--label "${LABEL_NAME}" --json number --jq 'length')
# Properly URL-encode the query parameter
QUERY="label:${LABEL_NAME}"
ENCODED_QUERY=$(printf '%s' "${QUERY}" | jq -sRr @uri)
echo "## Release Labeling Complete" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- **Label:** \`${LABEL_NAME}\`" >> $GITHUB_STEP_SUMMARY
echo "- **PRs labeled:** ${PR_COUNT}" >> $GITHUB_STEP_SUMMARY
echo "- **Issues labeled:** ${ISSUE_COUNT}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "[View all labeled items](https://github.com/${{ github.repository }}/issues?q=${ENCODED_QUERY})" >> $GITHUB_STEP_SUMMARY
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended",
":semanticCommits",
":semanticCommitTypeAll(chore)",
"group:allNonMajor"
],
"labels": ["dependencies"],
"prHourlyLimit": 2,
"prConcurrentLimit": 5,
"timezone": "Europe/Berlin",
"schedule": ["before 7am on monday"],
"packageRules": [
{
"matchUpdateTypes": ["minor", "patch"],
"automerge": true
},
{
"matchManagers": ["github-actions"],
"groupName": "GitHub Actions",
"automerge": true
},
{
"matchDepTypes": ["devDependencies"],
"automerge": true
}
]
}
# Security Policy
## Supported Versions
| Version | Supported |
| ------- | ------------------ |
| latest | :white_check_mark: |
| < latest | :x: |
## Reporting a Vulnerability
1. **Do NOT open a public GitHub issue** for security vulnerabilities
2. Report via [GitHub Security Advisories](https://github.com/{{ORG}}/{{REPO}}/security/advisories/new)
3. Or email: {{SECURITY_EMAIL}}
### What to Include
- Description of the vulnerability
- Steps to reproduce
- Potential impact
- Suggested fix (if any)
### Response Timeline
- **Initial response**: Within 48 hours
- **Status update**: Within 7 days
- **Resolution target**: Within 30 days
## Security Measures
This project implements:
- **SLSA Level 3** provenance for releases
- **Signed checksums** using Sigstore/Cosign
- **SBOM generation** for all releases
- **CodeQL** static analysis
- **Dependency scanning** via Dependabot
## Supply Chain Security
### Verifying Releases
#### Using GitHub Attestations (Recommended)
```bash
# Download release archive
gh release download v1.0.0 --pattern '*.tar.gz'
# Verify provenance via GitHub's built-in attestation
gh attestation verify {{BINARY}}.tar.gz --repo {{ORG}}/{{REPO}}
```
#### Using slsa-verifier (Legacy)
```bash
# Download release assets
gh release download v1.0.0 --pattern '*.tar.gz'
gh release download v1.0.0 --pattern '*.intoto.jsonl'
# Verify with slsa-verifier
slsa-verifier verify-artifact \
--provenance-path {{BINARY}}.intoto.jsonl \
--source-uri github.com/{{ORG}}/{{REPO}} \
{{BINARY}}.tar.gz
```
#### Checksum Verification
```bash
# Download checksum files
gh release download v1.0.0 --pattern 'checksums.txt*'
# Verify signature
cosign verify-blob \
--certificate checksums.txt.pem \
--signature checksums.txt.sig \
checksums.txt
# Verify checksums
sha256sum -c checksums.txt
```
## Branch Protection
The `main` branch requires:
- Pull request with 1+ approvals
- Passing CI status checks
- Up-to-date with base branch
- No force pushes
## OpenSSF Scorecard
This project is monitored by OpenSSF Scorecard:
[](https://securityscorecards.dev/viewer/?uri=github.com/{{ORG}}/{{REPO}})
# Example checkpoints.yaml for github-project skill
# Copy this to your skill repo root as checkpoints.yaml
version: 1
skill_id: github-project
mechanical:
# === FILE EXISTENCE CHECKS ===
- id: GH-01
type: file_exists
target: README.md
severity: error
desc: "README.md must exist"
# Brace expansion covers SPDX-style split-license filenames common in
# multi-licence skill repos plus the GNU COPYING convention. Single line
# because the runner's YAML parser doesn't unfold `>-` folded scalars
# for `target:` fields (it's bash regex, not a real YAML parser).
- id: GH-02
type: file_exists
target: "{LICENSE,LICENSE.md,LICENSE.txt,COPYING,COPYING.md,COPYING.txt,LICENSE-MIT,LICENSE-APACHE,LICENSE-APACHE-2.0,LICENSE-BSD,LICENSE-BSD-2-Clause,LICENSE-BSD-3-Clause,LICENSE-CC-BY-SA-4.0,LICENSE-CC0-1.0,LICENSE-GPL,LICENSE-GPL-2.0,LICENSE-GPL-3.0,LICENSE-LGPL,LICENSE-LGPL-3.0,LICENSE-AGPL,LICENSE-AGPL-3.0,LICENSE-MPL,LICENSE-MPL-2.0}"
severity: error
desc: >-
LICENSE file must exist. Accepts split-license naming (LICENSE-MIT,
LICENSE-CC-BY-SA-4.0, etc.) and the GNU COPYING convention.
- id: GH-03
type: file_exists
target: "{SECURITY.md,.github/SECURITY.md,docs/SECURITY.md}"
org_provides: SECURITY.md
severity: info
desc: "SECURITY.md should exist for vulnerability reporting. Satisfied org-wide via {owner}/.github/SECURITY.md when present."
- id: GH-04
type: file_exists
target: "{CONTRIBUTING.md,.github/CONTRIBUTING.md,docs/CONTRIBUTING.md}"
org_provides: CONTRIBUTING.md
severity: info
desc: "CONTRIBUTING.md should exist. Satisfied org-wide via {owner}/.github/CONTRIBUTING.md when present."
- id: GH-05
type: file_exists
target: "{.github/CODEOWNERS,CODEOWNERS,docs/CODEOWNERS}"
severity: warning
desc: >-
CODEOWNERS must exist in the repository itself for GitHub to assign
reviewers (only `.github/`, root, or `docs/` on the default branch are
recognised). Org-wide `.github` does NOT satisfy CODEOWNERS — that
mechanism only provides templates and community-health files, not
review-routing rules.
- id: GH-06
type: file_exists
target: "{.github/dependabot.yml,.github/dependabot.yaml,renovate.json,renovate.json5,.github/renovate.json,.github/renovate.json5,.renovaterc.json,.renovaterc.json5,.renovaterc}"
severity: warning
desc: >-
Dependency update automation should be configured (Dependabot or
Renovate). Accepts both .yml/.yaml for Dependabot and .json/.json5
for Renovate.
- id: GH-07
type: file_exists
target: "{.github/PULL_REQUEST_TEMPLATE.md,.github/pull_request_template.md,PULL_REQUEST_TEMPLATE.md,pull_request_template.md,docs/PULL_REQUEST_TEMPLATE.md,docs/pull_request_template.md}"
org_provides: "{PULL_REQUEST_TEMPLATE.md,pull_request_template.md}"
severity: info
desc: "PR template should exist (also accepted in docs/). Satisfied org-wide via {owner}/.github/PULL_REQUEST_TEMPLATE.md or pull_request_template.md when present."
# Issue templates: prefer the modern `.yml` form templates (structured
# fields, validation, required inputs) over legacy `.md` files. Accept
# either, but `.yml` is the recommended Netresearch standard.
- id: GH-08
type: file_exists
target: "{.github/ISSUE_TEMPLATE/bug_report.yml,.github/ISSUE_TEMPLATE/bug_report.md}"
org_provides: "{ISSUE_TEMPLATE/bug_report.yml,ISSUE_TEMPLATE/bug_report.md}"
severity: info
desc: "Bug report template should exist (prefer .yml form template). Satisfied org-wide via {owner}/.github/ISSUE_TEMPLATE/bug_report.yml or .md when present."
- id: GH-09
type: file_exists
target: "{.github/ISSUE_TEMPLATE/feature_request.yml,.github/ISSUE_TEMPLATE/feature_request.md}"
org_provides: "{ISSUE_TEMPLATE/feature_request.yml,ISSUE_TEMPLATE/feature_request.md}"
severity: info
desc: "Feature request template should exist (prefer .yml form template). Satisfied org-wide via {owner}/.github/ISSUE_TEMPLATE/feature_request.yml or .md when present."
# === CONTENT CHECKS ===
- id: GH-10
type: contains
target: README.md
pattern: "codecov.io"
severity: warning
desc: "README should have Codecov badge"
- id: GH-11
type: regex
target: README.md
pattern: "github.com/.*/actions/workflows"
severity: error
desc: "README should have CI status badge"
- id: GH-12
type: regex
target: README.md
pattern: "img.shields.io.*license"
severity: warning
desc: "README should have license badge"
# === DEPENDABOT CHECKS ===
- id: GH-13
type: regex
target: "{.github/dependabot.yml,.github/dependabot.yaml}"
pattern: 'package-ecosystem:\s*"?(composer|gomod|npm|pip)"?'
severity: warning
desc: "Dependabot should monitor language-specific dependencies (composer, gomod, npm, pip)"
- id: GH-14
type: regex
target: "{.github/dependabot.yml,.github/dependabot.yaml}"
pattern: 'package-ecosystem:\s*"?github-actions"?'
severity: warning
desc: "Dependabot should monitor GitHub Actions"
# === GITIGNORE ===
- id: GH-17
type: file_exists
target: .gitignore
severity: error
desc: ".gitignore must exist"
# === RENOVATE (alternative to Dependabot) ===
- id: GH-18
type: file_exists
target: renovate.json
severity: info
desc: "Renovate config may exist as alternative to Dependabot"
# === SECURITY CONFIG ===
# CodeQL / Scorecard can be a dedicated workflow file OR a job in another
# workflow that delegates to the netresearch reusable workflow. Same
# pattern as ER-19/20 in enterprise-readiness-skill PR #55.
- id: GH-19
type: regex
target: ".github/workflows/*.{yml,yaml}"
pattern: 'uses:[[:space:]]*github/codeql-action|uses:[[:space:]]*netresearch/[.]github/[.]github/workflows/codeql[.]yml'
follow_uses: true
severity: warning
desc: >-
CodeQL must be wired up. Satisfied by either a local workflow that uses
github/codeql-action directly, or a job that delegates to a reusable
workflow (e.g. netresearch/.github/.github/workflows/codeql.yml) whose
upstream body uses github/codeql-action. follow_uses: true expands the
search to fetch the reusable workflow contents one hop via gh api.
- id: GH-20
type: regex
target: ".github/workflows/*.{yml,yaml}"
pattern: 'uses:[[:space:]]*ossf/scorecard-action|uses:[[:space:]]*netresearch/[.]github/[.]github/workflows/scorecard[.]yml'
follow_uses: true
severity: info
desc: >-
OpenSSF Scorecard must be wired up. Satisfied by a local workflow using
ossf/scorecard-action directly, or a job that delegates to a reusable
workflow (e.g. netresearch/.github/.github/workflows/scorecard.yml) whose
upstream body uses ossf/scorecard-action. follow_uses: true expands the
search to fetch the reusable workflow contents one hop via gh api.
# === SLSA PROVENANCE ===
# regex_not with follow_uses: the legacy slsa-github-generator pattern must
# not appear in either the local workflow OR any one-hop reusable workflow
# body — otherwise a repo could "hide" the deprecated generator inside a
# delegated workflow and silently pass.
- id: GH-21
type: regex_not
target: ".github/workflows/*.{yml,yaml}"
pattern: 'slsa-framework/slsa-github-generator'
follow_uses: true
severity: warning
desc: "Should migrate from slsa-github-generator to actions/attest-build-provenance (cannot be SHA-pinned)"
# === AUTO-MERGE WORKFLOW CHECKS ===
- id: GH-23
type: file_exists
target: "{.github/workflows/auto-merge-deps.yml,.github/workflows/auto-merge.yml}"
severity: warning
desc: "Auto-merge workflow should exist for Dependabot/Renovate PRs"
# Auto-merge workflows can either:
# (a) delegate to the netresearch org reusable workflow
# (netresearch/.github/.github/workflows/auto-merge-deps.yml), which
# encapsulates the trigger, bot-gating, --auto merging, and dynamic
# merge-strategy detection; or
# (b) inline the full implementation with pull_request_target: + the
# same bot-gating and --auto patterns.
# GH-24..27 accept either path.
# Patterns allow optional quoting after `uses:` (YAML accepts uses: foo,
# uses: 'foo', uses: "foo"). Glob target accepts either auto-merge-deps.yml
# or auto-merge.yml (GH-23 already treats both as valid filenames).
# The netresearch reusable-workflow path is unique enough to match without
# caring about uses:-line quoting (uses: foo, uses: 'foo', uses: "foo" all
# contain the netresearch/... substring identically).
- id: GH-24
type: regex
target: ".github/workflows/auto-merge*.{yml,yaml}"
pattern: 'netresearch/\.github/\.github/workflows/auto-merge-deps\.yml|on:[[:space:]]*\n[[:space:]]*pull_request_target:'
severity: error
desc: "Auto-merge workflow must delegate to netresearch reusable workflow OR use pull_request_target trigger for bot PR write permissions"
- id: GH-25
type: regex
target: ".github/workflows/auto-merge*.{yml,yaml}"
pattern: 'netresearch/\.github/\.github/workflows/auto-merge-deps\.yml|github\.event\.pull_request\.user\.login'
severity: warning
desc: "Auto-merge should delegate to reusable workflow OR check github.event.pull_request.user.login (not github.actor which changes on reruns)"
- id: GH-26
type: regex
target: ".github/workflows/auto-merge*.{yml,yaml}"
pattern: 'netresearch/\.github/\.github/workflows/auto-merge-deps\.yml|--auto'
severity: warning
desc: "Auto-merge should delegate to reusable workflow OR use gh pr merge --auto to respect branch protection and merge queues"
- id: GH-27
type: regex
target: ".github/workflows/auto-merge*.{yml,yaml}"
pattern: 'netresearch/\.github/\.github/workflows/auto-merge-deps\.yml|gh api.*repos/\$'
severity: info
desc: "Auto-merge should delegate to reusable workflow OR dynamically detect merge strategy from repo settings"
# === COMPOSITE-ACTION SHA PINNING INSIDE WORKFLOWS ===
# Reusable-workflow refs like `uses: org/repo/.github/workflows/foo.yml@main`
# are exempt (see references/reusable-workflow-security.md — internal `@main`
# is allowed for full reusable workflows). Composite-action refs like
# `uses: org/repo/.github/actions/foo@main` are NOT exempt: they are resolved
# at the *consumer's* runner under the consumer's allow-list policy. A
# consumer that enforces "all actions must be SHA-pinned" will reject a
# reusable workflow that internally references a composite action by branch
# or tag, even though the workflow file itself is unchanged. SHA-pin
# composite-action refs to avoid breaking SHA-pinned consumers.
# PCRE negative lookahead matches refs that are NOT a 40-char hex SHA.
# The lookahead requires the 40 hex chars to be followed by quote/comment/
# whitespace/EOL — without that terminator, refs like `@<40hex>-tag` would
# spuriously pass because `\b` matches between hex and `-`.
- id: GH-34
type: regex_not
target: ".github/workflows/*.{yml,yaml}"
pattern: 'uses:\s+["'']?[A-Za-z0-9._-]+/[A-Za-z0-9._-]+/\.github/actions/[^@]+@(?![0-9a-f]{40}(?:["''#\s]|$))'
severity: error
desc: >-
Composite action references inside .github/workflows/*.yml must be
SHA-pinned (40-char hex). Branch/tag refs (@main, @v1) break consumers
that enforce SHA pinning when this workflow is called as a reusable
workflow. See references/reusable-workflow-pitfalls.md.
# === HARDEN-RUNNER FIRST-STEP CHECK ===
# Mechanical YAML-AST check via python yaml.safe_load (a regex-only check
# cannot reliably reason about "first step of each job" across multi-line
# YAML). Skipped if PyYAML is unavailable. Jobs that are pure
# reusable-workflow callers (job-level `uses:` instead of `steps:`) are
# exempt — they have no runner steps to harden.
- id: GH-35
type: command
target: |
command -v python3 >/dev/null 2>&1 || exit 0
python3 - <<'PY'
try:
import yaml
except ImportError:
import sys; sys.exit(0)
import glob, sys
bad = []
for f in sorted(glob.glob('.github/workflows/*.yml')
+ glob.glob('.github/workflows/*.yaml')):
try:
with open(f) as fh:
wf = yaml.safe_load(fh)
except Exception:
continue
if not isinstance(wf, dict):
continue
for jname, job in (wf.get('jobs') or {}).items():
if not isinstance(job, dict):
continue
# Skip reusable-workflow callers (no steps array).
if 'uses' in job and 'steps' not in job:
continue
steps = job.get('steps')
if not isinstance(steps, list) or not steps:
continue
first = steps[0] if isinstance(steps[0], dict) else {}
uses = first.get('uses', '') or ''
if not uses.startswith('step-security/harden-runner'):
bad.append(f"{f}::{jname}")
if bad:
sys.stderr.write("Jobs missing harden-runner as first step: "
+ ", ".join(bad) + "\n")
sys.exit(1)
sys.exit(0)
PY
severity: warning
desc: >-
Every workflow job with `steps:` should start with
`step-security/harden-runner` (audit egress). Jobs that are pure
reusable-workflow callers (job-level `uses:`) are exempt.
# === AUTO-APPROVE (pr-quality.yml) COPILOT RACE CONDITION ===
- id: GH-33
type: command
target: |
# If pr-quality.yml exists, verify it triggers on pull_request_review as
# well as pull_request_target. Without pull_request_review, the approval
# job races with Copilot and leaves PRs BLOCKED with empty reviewDecision.
# See references/auto-merge-guide.md → "Auto-Approve Race Condition
# with Copilot Reviewer".
WF=.github/workflows/pr-quality.yml
test -f "$WF" || exit 0 # not applicable if workflow doesn't exist
grep -qE '^[[:space:]]*pull_request_review[[:space:]]*:' "$WF"
severity: warning
desc: >-
pr-quality.yml should also trigger on pull_request_review to avoid the
Copilot auto-approve race condition (PR stuck BLOCKED with empty
reviewDecision)
# === BRANCH PROTECTION AUDIT ===
# GH-30 / GH-31 require GitHub API access (gh CLI + auth) which is outside
# the assessment runner's command allowlist. Declared as type: gh_api so the
# runner skips them with a clear evidence string instead of rejecting them.
# The semantically equivalent LLM-driven audit lives in GH-32 (llm_reviews).
- id: GH-30
type: gh_api
endpoint: "repos/{owner}/{repo}/branches/{default_branch}/protection"
json_path: ".enforce_admins.enabled"
severity: info
desc: >-
enforce_admins recommended on default branch so admins are also bound
by required status checks and review requirements. Severity is info
(not error) because netresearch's org default leaves this off — admins
retain bypass for emergency response. Tighten to true if your org
requires admin-bind policy.
- id: GH-31
type: gh_api
endpoint: "repos/{owner}/{repo}/branches/{default_branch}/protection"
json_path: ".required_conversation_resolution.enabled"
severity: error
desc: >-
required_conversation_resolution must be enabled. Without it, PR
merges may silently include unresolved bot-reviewer feedback —
including security issues like token leakage in build logs (see
netresearch/snipe-it-docker-compose-stack#17 where this gap let a
HIGH-severity Copilot/gemini-code-assist finding ship to main).
Apply via skills/github-project/scripts/init-branch-protection.sh
OWNER/REPO right after `gh repo create` + initial push, before
opening the first PR (script requires the default branch ref to
exist; exits 4 on empty repos). Combined with enforce_admins,
also ensures unresolved threads block ALL merges including admins
(audited by GH-32 llm_review; runner skips because gh API access
requires auth).
llm_reviews:
# === BRANCH PROTECTION + MERGE QUEUE COMPATIBILITY ===
- id: GH-22
domain: security
prompt: |
Check repository ruleset for merge queue + branch protection compatibility:
1. If merge_queue is enabled, require_last_push_approval MUST be false
(merge queue commits dismiss approvals, causing permanent blocks)
2. If require_code_owner_review is true, verify CODEOWNERS file exists
on the default branch (enabling without the file blocks all PRs)
3. For solo-maintainer repos: required_approving_review_count should be 1
(not 0) paired with an auto-approve workflow
Check via: gh api repos/OWNER/REPO/rulesets --jq '.[].rules[]'
severity: error
desc: "Verify branch protection rules are compatible with merge queue configuration"
# === AUTO-MERGE CORRECTNESS ===
- id: GH-28
domain: ci
prompt: |
Verify auto-merge workflow end-to-end correctness:
1. Trigger must be pull_request_target (not pull_request) — bot PRs
need write permissions from the base branch context
2. Bot detection must use github.event.pull_request.user.login
(not github.actor — actor changes on manual reruns)
3. Merge command must use --auto flag (respects branch protection,
merge queues, and required checks)
4. Merge strategy should be dynamically detected from repo settings
via gh api, not hardcoded (repos may enable different strategies)
5. If repo has branch protection requiring reviews, the workflow must
also approve the PR (gh pr review --approve) before enabling auto-merge
6. Check for step-security/harden-runner presence
Report specific issues found.
severity: warning
desc: "Verify auto-merge workflow uses correct trigger, bot detection, merge flags, and strategy detection"
- id: GH-29
domain: ci
prompt: |
Check if pr-quality.yml or auto-approve workflow correctly handles bot accounts:
1. Bot accounts (dependabot[bot], renovate[bot]) have author_association
of NONE or CONTRIBUTOR — they are NOT org members/collaborators
2. If auto-approve checks author_association for OWNER/MEMBER/COLLABORATOR,
it will NEVER approve bot PRs — this is a common misconfiguration
3. Correct patterns: check user.login explicitly for known bots,
OR use gh api collaborators permission check (which works for apps
with collaborator access)
4. If branch protection requires approving reviews, bot PRs will be
permanently BLOCKED without a working auto-approve mechanism
Report if bot approval flow has this misconfiguration.
severity: error
desc: "Verify auto-approve workflow correctly handles bot accounts (author_association is NONE for bots)"
# === BRANCH PROTECTION ENFORCEMENT AUDIT ===
- id: GH-32
domain: security
prompt: |
Audit branch protection enforcement on the default branch:
1. Run: gh api repos/OWNER/REPO/branches/BRANCH/protection --jq '.enforce_admins.enabled'
Recommended (informational only — see GH-30). When false, repository
admins can bypass branch protection rules. Netresearch's org default
leaves this off so admins retain bypass for emergency response;
tighten to true only if your org requires admin-bind policy. Do NOT
report this as an error on its own — flag it as info/advisory.
2. Run: gh api repos/OWNER/REPO/branches/BRANCH/protection --jq '.required_conversation_resolution.enabled'
Must be true. Without this, unresolved review threads do not block merges.
This is the primary error-level enforcement check (paired with GH-31).
3. Note the interaction: enforce_admins=false means admins COULD bypass
required_conversation_resolution. Surface this as an advisory trade-off,
not a blocking failure, since it matches the documented org default.
4. If using rulesets instead of branch protection, check the equivalent:
gh api repos/OWNER/REPO/rulesets --jq '.[].rules[] | select(.type=="pull_request") | .parameters'
Look for required_review_thread_resolution: true and bypass_actors being empty.
Report required_conversation_resolution gaps as errors; report
enforce_admins gaps as info (advisory).
severity: error
desc: "Verify required_conversation_resolution is enabled (error); enforce_admins is advisory only (see GH-30)."
# === PR REVIEW COMPLETENESS PROCESS CHECK ===
- id: GH-36
domain: pr-process
prompt: |
When a PR's review feedback is being addressed, before declaring it
"ready to merge" or "all comments addressed":
1. Query unresolved review threads via GraphQL (first:100 is the
GraphQL maximum; for PRs at the cap, paginate with
pageInfo.hasNextPage / endCursor + after:):
gh api graphql -f query='query($owner:String!,$repo:String!,$pr:Int!){
repository(owner:$owner,name:$repo){pullRequest(number:$pr){
reviewThreads(first:100){
pageInfo{hasNextPage endCursor}
nodes{id isResolved comments(first:1){nodes{body path line}}}
}
}}
}' -f owner=OWNER -f repo=REPO -F pr=NUMBER \
--jq '.data.repository.pullRequest.reviewThreads.nodes
| map(select(.isResolved == false))'
2. The count of unresolved threads must be exactly zero. If
pageInfo.hasNextPage is true, paginate before concluding.
3. If non-zero, list each thread with the first comment's body
(truncated to ~100 chars) plus path/line so the user can see
exactly what's outstanding.
Common failure mode: addressing only the threads visible at the top
of the PR discussion, missing inline-code threads or threads from
earlier reviewers. The GraphQL query is the single source of truth —
do not rely on the PR conversation tab alone.
Report: "All threads resolved" OR "N unresolved thread(s): <list>".
severity: info
desc: >-
Before declaring PR review feedback addressed, verify zero unresolved
review threads via GraphQL (not just the conversation tab).
# === SUBJECTIVE CHECKS (require LLM judgment) ===
- id: GH-15
domain: repo-health
rubric: references/llm-rubric.md#badge-order
severity: warning
desc: "Verify README badges are in the correct order"
- id: GH-16
domain: repo-health
prompt: |
Verify the README has a clear structure with these sections:
- Installation/Setup
- Usage/Configuration
- Development (tests, linting)
- License
- Credits/Contributors
Check the actual section headers in README.md and report if it matches.
severity: info
desc: "README should have standard structure with key sections"
[
{
"name": "setup_branch_protection",
"prompt": "Set up branch protection for this repo requiring PR reviews and status checks",
"assertions": [
{
"type": "tool_use",
"tool": "Bash",
"pattern": "gh api.*repos/.*/branches/.*/protection"
},
{
"type": "content",
"pattern": "(branch protection|ruleset|required_pull_request_reviews|enforce_admins)"
}
]
},
{
"name": "fix_blocked_pr_merge",
"prompt": "Fix this blocked PR merge - PR #42 shows BLOCKED status",
"assertions": [
{
"type": "tool_use",
"tool": "Bash",
"pattern": "gh api graphql.*mergeStateStatus"
},
{
"type": "content",
"pattern": "(BLOCKED|reviewDecision|mergeStateStatus|reviewThreads)"
}
]
},
{
"name": "setup_auto_merge_workflow",
"prompt": "Set up auto-merge for Dependabot and Renovate PRs in this repository",
"assertions": [
{
"type": "content",
"pattern": "pull_request_target"
},
{
"type": "content",
"pattern": "(user\\.login|dependabot\\[bot\\]|renovate\\[bot\\])"
},
{
"type": "content",
"pattern": "--auto"
}
]
},
{
"name": "diagnose_auto_merge_failure",
"prompt": "Auto-merge is not working on PR #15 - Dependabot PR stays open after checks pass",
"assertions": [
{
"type": "content",
"pattern": "(pull_request_target|user\\.login|github\\.actor|autoMergeRequest)"
},
{
"type": "content",
"pattern": "(bypass|allow_auto_merge|merge strategy|--auto)"
}
]
},
{
"name": "solo_maintainer_pr_stuck",
"prompt": "I'm a solo maintainer and my PRs are stuck on REVIEW_REQUIRED even though I'm the only contributor",
"assertions": [
{
"type": "content",
"pattern": "(auto-approve|pr-quality|required_approving_review_count)"
},
{
"type": "content",
"pattern": "(solo maintainer|collaborator|write.*permission|admin)"
}
]
},
{
"name": "setup_codeowners",
"prompt": "Set up CODEOWNERS for this repository with automatic review assignments",
"assertions": [
{
"type": "content",
"pattern": "(CODEOWNERS|\\.github/CODEOWNERS)"
},
{
"type": "content",
"pattern": "(@|review)"
}
]
},
{
"name": "fix_github_actions_failure",
"prompt": "CI is failing on this repo - the build workflow keeps erroring out on the latest push",
"assertions": [
{
"type": "tool_use",
"tool": "Bash",
"pattern": "gh run (list|view)"
},
{
"type": "content",
"pattern": "(log-failed|rerun|workflow)"
}
]
},
{
"name": "migrate_master_to_main",
"prompt": "Migrate the default branch from master to main for this repository",
"assertions": [
{
"type": "content",
"pattern": "(default_branch|default-branch)"
},
{
"type": "content",
"pattern": "(branch -m|rename|master.*main)"
}
]
},
{
"name": "setup_dependabot",
"prompt": "Configure Dependabot for a Go project that also uses GitHub Actions",
"assertions": [
{
"type": "content",
"pattern": "dependabot\\.yml"
},
{
"type": "content",
"pattern": "(gomod|github-actions|package-ecosystem)"
}
]
},
{
"name": "codeql_default_setup_conflict",
"prompt": "CodeQL is failing with 'analyses from advanced configurations cannot be processed when default setup is enabled'",
"assertions": [
{
"type": "content",
"pattern": "(default-setup|not-configured|code-scanning)"
},
{
"type": "tool_use",
"tool": "Bash",
"pattern": "gh api.*code-scanning"
}
]
},
{
"name": "signed_commits_merge_failure",
"prompt": "Merge is failing with 'Rebase merges cannot be automatically signed by GitHub' - how do I fix this?",
"assertions": [
{
"type": "content",
"pattern": "(squash|merge commit|allow_squash_merge|signed)"
},
{
"type": "content",
"pattern": "(rebase.*cannot.*sign|merge strategy|auto-detect)"
}
]
},
{
"name": "pr_too_many_commits",
"prompt": "My PR on a fork shows 38 commits but I only added 1 - the merge base seems wrong",
"assertions": [
{
"type": "content",
"pattern": "(merge base|close.*reopen|fork)"
},
{
"type": "content",
"pattern": "(gh pr close|cache|recalculate)"
}
]
},
{
"name": "enforce_admins_audit",
"prompt": "Audit whether admins can bypass branch protection on the default branch of this repo",
"assertions": [
{
"type": "content",
"pattern": "enforce_admins"
},
{
"type": "tool_use",
"tool": "Bash",
"pattern": "gh api.*protection"
}
]
},
{
"name": "resolve_review_threads",
"prompt": "PR #23 has unresolved review threads blocking merge - help me find and resolve them",
"assertions": [
{
"type": "content",
"pattern": "(reviewThreads|isResolved|resolveReviewThread)"
},
{
"type": "tool_use",
"tool": "Bash",
"pattern": "gh api graphql"
}
]
},
{
"name": "openssf_scorecard_improvement",
"prompt": "Our OpenSSF Scorecard score is low - what should we fix first?",
"assertions": [
{
"type": "content",
"pattern": "(Scorecard|Token-Permissions|Branch-Protection|Pinned-Dependencies)"
},
{
"type": "content",
"pattern": "(workflow.*write|SHA.*pin|required_approving_review_count)"
}
]
},
{
"name": "workflow_permissions_least_privilege",
"prompt": "Fix the workflow permissions in our CI - we have write permissions at the workflow level",
"assertions": [
{
"type": "content",
"pattern": "(job-level|workflow-level|permissions)"
},
{
"type": "content",
"pattern": "(contents: read|pull-requests: write|least.privilege)"
}
]
},
{
"name": "setup_release_labeling",
"prompt": "Set up automated release labeling so PRs and issues get labeled when a release is published",
"assertions": [
{
"type": "content",
"pattern": "(release-labeler|released:v)"
},
{
"type": "content",
"pattern": "(release.*published|label|announcement)"
}
]
},
{
"name": "merge_queue_troubleshooting",
"prompt": "PRs keep getting stuck in the merge queue after force-pushing a rebase",
"assertions": [
{
"type": "content",
"pattern": "(merge queue|stale review|dismiss_stale_reviews)"
},
{
"type": "content",
"pattern": "(force.push|re-queue|resolveReviewThread|auto-approve)"
}
]
},
{
"name": "copilot_reviewer_race_condition",
"prompt": "Auto-approve keeps getting skipped and PRs stay REVIEW_REQUIRED - we use Copilot as a reviewer",
"assertions": [
{
"type": "content",
"pattern": "(race condition|Copilot|pending reviewer)"
},
{
"type": "content",
"pattern": "(re-run|rerun|auto-approve|COMMENTED)"
}
]
},
{
"name": "workflow_file_pr_cannot_merge",
"prompt": "A Dependabot PR that updates a GitHub Actions version can't be auto-merged - it modifies workflow files",
"assertions": [
{
"type": "content",
"pattern": "(\\.github/workflows/|workflow.*files|GITHUB_TOKEN)"
},
{
"type": "content",
"pattern": "(manual.*merge|workflows.*permission|cannot.*auto-merge)"
}
]
}
]
actionlint - GitHub Actions Workflow Linter
Static analysis tool for GitHub Actions workflow files. Catches syntax errors, type mismatches, deprecated features, and security issues before pushing to CI.
Repository: https://github.com/rhysd/actionlint
Installation
# Go install
go install github.com/rhysd/actionlint/cmd/actionlint@latest
# Homebrew
brew install actionlint
# Download binary (Linux)
curl -sL https://github.com/rhysd/actionlint/releases/latest/download/actionlint_linux_amd64.tar.gz | tar xz -C /usr/local/bin actionlint
# Docker
docker run --rm -v "$(pwd):/repo" -w /repo rhysd/actionlint:latestCommand-Line Usage
# Lint all workflows in .github/workflows/
actionlint
# Lint a specific file
actionlint .github/workflows/ci.yml
# Colored output (auto-detects terminal)
actionlint -color
# JSON output (for CI pipelines and editors)
actionlint -format '{{json .}}'
# SARIF output (for GitHub Code Scanning)
actionlint -format sarif > actionlint.sarif
# Verbose output (shows checked files)
actionlint -verbose
# Ignore specific rules
actionlint -ignore 'SC2086' # Ignore shellcheck rule
actionlint -ignore 'label "self-hosted"' # Ignore runner label warning
# Specify shellcheck binary path
actionlint -shellcheck /usr/local/bin/shellcheck
# Disable shellcheck integration
actionlint -shellcheck=
# Disable pyflakes integration
actionlint -pyflakes=
# Stdin mode (pipe workflow content)
cat .github/workflows/ci.yml | actionlint -stdin-filename ci.yml -Flag Reference
| Flag | Description |
|---|---|
-color | Force colored output |
-no-color | Disable colored output |
-format <template> | Custom output format (Go template) |
-ignore <pattern> | Regex pattern for errors to ignore (repeatable) |
-shellcheck <path> | Path to shellcheck binary (empty to disable) |
-pyflakes <path> | Path to pyflakes binary (empty to disable) |
-stdin-filename <name> | Filename for stdin input |
-verbose | Show verbose output |
-debug | Show debug output |
-oneline | One-line compact output |
-config-file <path> | Path to configuration file |
Configuration File
Create .github/actionlint.yaml in the repository root (auto-detected):
# .github/actionlint.yaml
# Define custom runner labels your org uses
self-hosted-runner:
labels:
- linux-large
- ubuntu-24.04-16core
- arm64
# Patterns of errors to ignore (regex)
ignore:
# Ignore shellcheck SC2086 (double quote to prevent globbing)
- 'SC2086'
# Ignore specific expression warnings
- 'label "self-hosted" is unknown'
# Configure paths (available since actionlint 1.7+)
paths:
shellcheck: /usr/local/bin/shellcheck
pyflakes: "" # disable pyflakesConfiguration for Common Project Types
TYPO3 Extension:
self-hosted-runner:
labels: []
ignore:
- 'SC2086' # phpunit commands often need unquoted varsGo Project:
self-hosted-runner:
labels: []
ignore: []Monorepo with Custom Runners:
self-hosted-runner:
labels:
- linux-large
- gpu-runner
- arm64-builder
ignore:
- 'label "linux-large" is unknown'Common Error Codes and Fixes
Expression Syntax Errors
Error: unexpected token "}" while parsingCause: Malformed ${{ }} expression. Fix: Check for unbalanced braces, missing operators, or invalid function calls.
# Bad
if: ${{ github.event_name == 'push' && }}
# Good
if: ${{ github.event_name == 'push' }}Undefined Action Inputs/Outputs
Error: input "node-versions" is not defined in action "actions/setup-node@v4"Cause: Typo in action input name. Fix: Check the action's action.yml for correct input names.
# Bad
- uses: actions/setup-node@v4
with:
node-versions: '22' # wrong: plural
# Good
- uses: actions/setup-node@v4
with:
node-version: '22' # correct: singularYAML Type Errors
Error: "permissions" section should be mappingCause: Wrong YAML structure for permissions block. Fix: Use mapping syntax, not sequence.
# Bad
permissions:
- contents: read
# Good
permissions:
contents: readDeprecated Action Versions
Error: the runner of "ubuntu-18.04" is no longer supportedCause: Using a deprecated runner image. Fix: Update to a supported runner version.
# Bad
runs-on: ubuntu-18.04
# Good
runs-on: ubuntu-24.04Missing Permissions Declarations
actionlint checks that workflow-level or job-level permissions are set when using GITHUB_TOKEN. This aligns with the principle of least privilege.
# Add explicit permissions
permissions:
contents: read
pull-requests: writeShellCheck Issues in run: Blocks
Error: shellcheck reported issue in this script: SC2086Cause: ShellCheck found an issue in a run: block. actionlint embeds shellcheck analysis for bash/sh scripts. Fix: Follow the shellcheck recommendation, or ignore with inline directive.
# Fix: quote the variable
- run: echo "$MY_VAR"
# Or ignore inline (not recommended)
- run: |
# shellcheck disable=SC2086
echo $MY_VARContext/Object Property Access
Error: property "conclusion" is not defined in object type "steps"Cause: Accessing a step output without using the step ID. Fix: Use steps.<step-id>.outputs.<name> or steps.<step-id>.conclusion.
steps:
- id: build
run: echo "done"
- if: steps.build.conclusion == 'success'
run: echo "Build succeeded"ShellCheck Integration
actionlint embeds ShellCheck to analyze shell scripts in run: blocks. This provides:
- Variable quoting warnings (SC2086)
- Unused variable detection (SC2034)
- Command injection risks (SC2091)
- Bash-specific syntax issues
How it works: 1. actionlint extracts run: block content 2. Detects shell type from shell: key (defaults to bash) 3. Runs shellcheck on the extracted script 4. Maps errors back to workflow file line numbers
Controlling shellcheck rules:
# Per-block: use shellcheck directives
- run: |
# shellcheck disable=SC2086,SC2034
echo $UNQUOTED_VAR
# Globally: ignore in actionlint config
# .github/actionlint.yaml
ignore:
- 'SC2086'
# Disable shellcheck entirely
# Run: actionlint -shellcheck=Common shellcheck rules in workflows:
| Rule | Description | Fix |
|---|---|---|
| SC2086 | Double quote to prevent globbing | "$VAR" instead of $VAR |
| SC2034 | Variable appears unused | Remove or export the variable |
| SC2046 | Quote to prevent word splitting | "$(command)" instead of $(command) |
| SC2129 | Use >> file grouped, not repeated | Group multiple appends |
| SC2155 | Declare and assign separately | local var; var=$(cmd) |
Pre-commit Hook Integration
Using pre-commit framework
# .pre-commit-config.yaml
repos:
- repo: https://github.com/rhysd/actionlint
rev: v1.7.11 # check for latest version
hooks:
- id: actionlintManual git hook
#!/usr/bin/env bash
# .git/hooks/pre-commit
# Check if any workflow files are staged
WORKFLOW_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.github/workflows/.*\.ya?ml$')
if [ -n "$WORKFLOW_FILES" ]; then
echo "Running actionlint on modified workflows..."
echo "$WORKFLOW_FILES" | xargs actionlint
if [ $? -ne 0 ]; then
echo "actionlint failed. Fix errors before committing."
exit 1
fi
fiCI Integration Patterns
Direct Run in CI
name: Lint Workflows
on:
pull_request:
paths:
- '.github/workflows/**'
jobs:
actionlint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install actionlint
run: |
curl -sL https://github.com/rhysd/actionlint/releases/latest/download/actionlint_linux_amd64.tar.gz \
| tar xz -C /usr/local/bin actionlint
- name: Run actionlint
run: actionlint -colorWith reviewdog (Inline PR Comments)
name: Lint Workflows
on:
pull_request:
paths:
- '.github/workflows/**'
permissions:
contents: read
pull-requests: write
jobs:
actionlint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: reviewdog/action-actionlint@v1
with:
fail_level: error
reporter: github-pr-review # inline comments on PRImportant: Always set fail_level: error with reviewdog. Without it, actionlint warnings appear as annotations but do not fail the check, allowing broken workflows to be merged.
SARIF Upload (GitHub Code Scanning)
name: Lint Workflows
on:
push:
branches: [main]
pull_request:
paths:
- '.github/workflows/**'
permissions:
contents: read
security-events: write
jobs:
actionlint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install actionlint
run: |
curl -sL https://github.com/rhysd/actionlint/releases/latest/download/actionlint_linux_amd64.tar.gz \
| tar xz -C /usr/local/bin actionlint
- name: Run actionlint (SARIF)
run: actionlint -format sarif > actionlint.sarif
continue-on-error: true
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: actionlint.sarifCombined with Other Linters
name: CI Quality
on: [pull_request]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Lint workflows
- name: actionlint
run: |
curl -sL https://github.com/rhysd/actionlint/releases/latest/download/actionlint_linux_amd64.tar.gz \
| tar xz -C /usr/local/bin actionlint
actionlint -color
# Lint Dockerfiles
- uses: hadolint/hadolint-action@v3.1.0
with:
dockerfile: Dockerfile
# Lint shell scripts
- name: shellcheck
run: shellcheck scripts/*.shEditor Integration
- VS Code: Install actionlint extension for real-time feedback
- Vim/Neovim: Use with ALE or null-ls for inline diagnostics
- JetBrains: Configure as external tool with file watcher on
*.ymlin.github/workflows/
Troubleshooting
"shellcheck is not installed"
actionlint optionally uses shellcheck. Install it or disable with actionlint -shellcheck=.
False positives on reusable workflows
Reusable workflow inputs/outputs may not be fully validated. Use -ignore for known false positives.
Custom actions not recognized
actionlint cannot resolve local actions (./) or private actions at lint time. Ignore with:
# .github/actionlint.yaml
ignore:
- 'could not read action'Large matrix expressions
Complex matrix expressions with fromJSON() may cause type-check warnings. These are usually safe to ignore:
ignore:
- 'fromJSON'yamllint empty-lines rejects trailing blank lines
Unrelated to actionlint itself, but bites right next to it in CI: if you run yamllint in the same lane, its default empty-lines rule rejects a file that ends with more than one newline. Workflow files end with exactly one newline.
Generators that use echo "$CONTENT" > file.yml frequently add a trailing blank; prefer:
printf '%s\n' "$CONTENT" > file.ymlVerify after writing:
# Bytes at end of file — should be `0a` (one newline), not `0a0a`.
tail -c 2 file.yml | xxd -pAgentic Workflows Reference
Authoring AI-agent workflows that run on GitHub Actions: gh-aw for compiling Markdown specs into hardened Actions YAML, and gh-aw-firewall (awf) for sandboxing the agent process inside the runner.
When to use
- Building issue-triage bots, PR-review agents, doc-update agents, scheduled maintenance bots that run as GitHub Actions workflows.
- Hardening an existing agent workflow (permissions narrowing, SHA pinning, output filtering, tool allowlists) without hand-rolling the boilerplate.
- Adding egress control and credential isolation to an agent that calls external LLM/MCP endpoints.
When NOT to use
- Non-agent CI workflows (lint, test, release). Use the standard patterns in
repo-setup-guide.mdandreusable-workflow-security.mdinstead. - One-off
ghscripts run by a maintainer locally —gh-awtargets workflows committed to a repo.
gh-aw — GitHub Agentic Workflows
gh CLI extension that compiles a natural-language Markdown spec into a hardened GitHub Actions workflow YAML. Each spec is a single Markdown file with YAML front-matter (trigger, permissions, engine, tools) plus a body that describes what the agent should do in prose.
Source: https://github.com/github/gh-aw
Install
gh extension install github/gh-awWhat the compiler enforces
- SHA-pinned `uses:` for the agent-runtime actions it injects — no
@main, no floating tags. - Narrowed `permissions:` at the job level, derived from the front-matter declaration. Default is read-only; write scopes are opt-in per workflow.
- Input sanitisation for untrusted fields (issue body, PR title, comment body) that are interpolated into the agent prompt — these are passed via env vars, not
${{ }}expansion inrun:blocks. Seeworkflow-bash-patterns.mdfor the underlying injection class. - Sanitised-output gating for any step that uses agent output to perform a write (create comment, push branch, file issue). Agent output is filtered before it reaches the GitHub API call.
- Tool allowlists for the MCP servers and
gh/bashsurfaces the agent is permitted to call. Anything outside the allowlist is refused at runtime. - Engine pluggability — Claude, GitHub Models, and others can be swapped via front-matter without rewriting the workflow.
Minimal spec shape
---
on:
issues:
types: [opened]
permissions:
issues: write
contents: read
engine: claude
tools:
github:
allowed: [add_issue_comment, get_issue]
---
# Triage Bot
Read the issue body and add one of the labels: bug, feature, question.
Post a one-sentence comment explaining the choice.Saved as .github/aw/triage-bot.md, gh aw compile turns this into a fully-formed .github/workflows/triage-bot.yml with the hardening above baked in. Treat the compiled YAML as generated output — edit the Markdown, recompile, commit both.
Relationship to the rest of the skill
- Compiled workflows still need org-level SHA pinning (
org-security-settings.md) and supply-chain auditing of any non-default action they pull in (reusable-workflow-security.md). - For reusable-workflow consumers, the gh-aw output is the "workflow" — apply
reusable-workflow-pitfalls.mdto the surrounding wiring.
gh-aw-firewall — Agent egress firewall (awf)
Binary that wraps the compiled agent workflow in a Docker sandbox with a Squid proxy enforcing a domain allowlist, plus an optional API-proxy sidecar that holds the LLM credentials so the agent process itself never sees them.
Source: https://github.com/github/gh-aw-firewall
Components
| Component | Role |
|---|---|
| Squid proxy | Enforces an outbound domain allowlist. All agent egress goes through it. |
| Agent container | The compiled gh-aw workflow runs here, network-restricted to the proxy. |
| API-proxy sidecar (optional) | Holds the Anthropic/OpenAI/GitHub Models API key; injects auth headers; agent only sees the proxy endpoint, never the secret. |
When to layer awf on top of gh-aw
- Agent calls third-party LLM/MCP endpoints and you want the API key out of the agent process's environment (defence against prompt-injection-driven exfiltration).
- You want a hard, declarative allowlist of domains the agent may reach — not a
harden-runnerpost-hoc audit. - The workflow ingests untrusted content (issue bodies, PR diffs from forks, web fetches) and the agent has any write permission.
When you DON'T need awf
- Agent only uses
GITHUB_TOKENvia theghCLI inside the same repo and makes no external calls.gh-aw's permission narrowing is sufficient. - Workflow is on a private repo with no fork PR triggers and you trust all input authors.
awf vs step-security/harden-runner
They are not mutually exclusive and solve adjacent problems:
harden-runner | awf | |
|---|---|---|
| Scope | The GitHub Actions runner VM | The agent process inside the workflow |
| Mechanism | eBPF egress monitoring + allowlist on the runner | Squid proxy + Docker network isolation around the agent container |
| Credential isolation | No — secrets in env are visible to all steps | Yes (with API-proxy sidecar) — LLM key never reaches agent |
| Detection vs prevention | Both, configurable | Prevention (proxy refuses non-allowlisted egress) |
| Applies to | Any workflow | Agent workflows specifically |
A defence-in-depth setup uses both: harden-runner at the runner level (covers all steps including actions/checkout, setup-*, etc.) and awf around the agent container (adds credential isolation and a narrower allowlist scoped to LLM/MCP endpoints).
Audit checklist for an agentic workflow
Before merging a new agent workflow:
- [ ] Spec is committed alongside the generated YAML; CI regenerates and diffs to detect drift.
- [ ] Front-matter
permissions:is the minimum needed — nowrite-all, nosecrets: inherit. - [ ] Tool allowlist is explicit — no wildcard MCP servers or
bashwith unrestricted command set. - [ ] Untrusted input fields go through env-var passing, not direct prompt interpolation (gh-aw does this by default — verify in compiled YAML).
- [ ] If the agent has any write scope and ingests fork-PR content:
awfis enabled or the trigger excludes untrusted authors. - [ ] LLM credentials are stored as repository or environment secrets, never in workflow files; with
awf, they live in the API-proxy sidecar and are not exposed to the agent step. - [ ] Agent workflow has
concurrency:scoped to the triggering issue/PR (not a static repo-wide group) so concurrent issues/PRs don't serialise unnecessarily — e.g.group: ${{ github.workflow }}-${{ github.event.issue.number || github.event.pull_request.number || github.sha }}.
Related
reusable-workflow-security.md— supply-chain trust + SHA pinning that applies to any action the compiled workflow references.workflow-bash-patterns.md— the injection class that gh-aw's input-sanitisation flow defends against.security-config.md—harden-runnersetup at the runner level.org-security-settings.md— org-widesha_pinning_requiredsetting thatgh-awoutput already complies with.
AI Reviewer Pushback Patterns
How to evaluate, respond to, and resolve review comments from automated AI reviewers (GitHub Copilot, gemini-code-assist, CodeRabbit, Sourcery, Codium / PR-Agent, etc.) without either rubber-stamping wrong advice or ignoring valid feedback.
When to use
- An AI reviewer left comments on a PR and you need to decide which to apply.
- A reviewer flagged a "high priority" issue that contradicts your local verification.
- You're being asked to change config, code, or APIs based on AI-generated suggestions.
The core problem
AI code reviewers produce a mix of:
1. Genuinely useful catches — typos, missing null checks, leaked secrets, unused imports, accessibility issues, the kind of thing a careful reviewer would also notice. 2. Stylistic preferences — usually low-stakes, often defensible either way. 3. Plausible-sounding but factually wrong claims — invented API names, deprecated patterns presented as current, version-status claims that lag reality.
Categories 1 and 2 are easy. Category 3 is the dangerous one: an AI reviewer states with full confidence that "the field is named X" or "this version is not yet released," and a hurried maintainer applies the change to clear the review. The fix breaks the build, regresses security, or introduces a real bug.
Common failure modes to watch for
Field-name / API-name hallucination
The reviewer asserts that a config key, function, or type is named X. The name doesn't exist in current docs (or never existed at all).
Real examples seen in the wild:
- gemini-code-assist suggested
ignoredBuilds:for pnpm 11. Pnpm 11 has noignoredBuildssetting — the legacy field was namedignoredBuiltDependencies(deprecated 10.26, removed in 11), and the modern equivalent isallowBuilds: { pkg: false }. - Suggesting
secrets: inherit"for simplicity" in reusable workflows — actively dangerous, exposes the entire org-secret namespace. - Recommending TYPO3 v11 ViewHelper namespaces in v13/v14 code.
Tell: the suggestion always sounds confident, often quotes a documentation-shaped block, and doesn't link to the docs.
Stale knowledge of release status
The reviewer claims a current release "is not out yet," recommends an outdated minimum version, or asserts a feature "doesn't exist" when it has shipped. Training cutoffs are months to years behind, and bots rarely declare their knowledge boundary.
Illustrative shapes (specific versions cited here will go stale — treat as examples of the pattern, not authoritative current state):
- "Language X version N is not released yet" — when it is. Verify against the language's release page.
- "Use Node N as the maximum supported version" — when a newer LTS is current.
- "Framework F version N has not been released" — when an N.x release is already in production.
Tell: version assertions without a release-date check, or recommendations that pull constraints downward from what your CI matrix is already running.
Pattern advice frozen at a past major
The reviewer suggests a deprecated pattern that was current in their training data: jQuery for vanilla DOM tasks, Vue 2 Options API in a Vue 3 codebase, deprecated GitHub Actions inputs (fail_on_error instead of fail_level), CKEditor 4 plugin shapes used in a CKEditor 5 codebase.
Tell: the advice contradicts code patterns already in the same file or neighbouring files.
Inverting a security control
The reviewer recommends weakening a control "to fix the failing build." The build is failing because the control is correctly enforcing a new default; the right fix is configuration, not weakening.
Real examples:
- "Set
strict-peer-dependencies=false" when the underlying issue is a missing peer. - "Set
engine-strict=false" when engines is correctly rejecting an unsupported version. - "Disable harden-runner" when an egress is being legitimately blocked.
Tell: the suggested change makes the symptom go away by removing the check that produced the symptom.
The pushback workflow
When you suspect a reviewer comment is wrong, follow this sequence before changing any code:
1. Verify against primary sources
Open the official docs for the library/tool the reviewer is talking about. Look for:
- The exact field/API name they suggested. Is it in the current docs? Was it ever in the docs?
- The version they reference. Is that the current major? Was the feature/deprecation they mention introduced/removed in a release that has already shipped?
- The release-status claim. Cross-check against the project's release page or registry.
If you have a documentation lookup tool available (e.g. the Context7 MCP server, which fetches current library docs on demand), use it. Otherwise fetch the docs URL directly. Treat AI training-data as a stale snapshot.
2. Check empirical evidence already on the PR
If the reviewer is claiming "this won't work" but CI is green, that's strong evidence the configuration does work. Note:
- The specific check name (e.g.
build-and-push). - The conclusion (
SUCCESS). - The pnpm/node/php/library version the runner used (often visible in the install-step log).
A green CI run on a non-trivial check is empirical evidence that overrides a confident textual assertion. Cite it.
3. Read the bot's "why"
Some bots include a rationale block. If the rationale references behavior from a deprecated version, an old release line, or a feature that was removed, that's diagnostic. Quote it back when you reply.
4. Decide
Three legitimate outcomes:
1. Apply the change. The reviewer is right. Make the change in a follow-up commit and reply linking the commit SHA. 2. Push back. The reviewer is wrong. Reply on the thread with citations and resolve. Do NOT change the code. 3. Compromise. The reviewer has a point but the suggested implementation is wrong. Reply explaining the alternative, link the commit that does the right thing.
5. Reply directly to the thread
Always reply to the review-comment thread, not as a top-level PR comment. The reply preserves context and lets future readers follow the disagreement.
# Find thread IDs
gh api graphql -f query='query {
repository(owner: "OWNER", name: "REPO") {
pullRequest(number: NUMBER) {
reviewThreads(first: 50) {
nodes {
id
isResolved
comments(first: 1) {
nodes { databaseId author { login } body }
}
}
}
}
}
}' --jq '.data.repository.pullRequest.reviewThreads.nodes[] |
{id, isResolved,
author: .comments.nodes[0]?.author?.login,
snippet: (.comments.nodes[0]?.body // "")[0:100]}'
# Reply to a thread
gh api graphql \
-f query='mutation($body: String!, $tid: ID!) {
addPullRequestReviewThreadReply(input: {body: $body, pullRequestReviewThreadId: $tid}) {
comment { url }
}
}' \
-f tid="PRRT_xxx" \
-f body="See pnpm 10.26 release notes (https://...) — the field is named allowBuilds and takes a map, not an array."6. Resolve the thread
After replying, resolve. Don't leave threads open as a passive-aggressive disagreement marker — it makes the PR look unsettled to future reviewers and can block merges on repos that require thread resolution.
gh api graphql \
-f query='mutation($tid: ID!) {
resolveReviewThread(input: {threadId: $tid}) {
thread { isResolved }
}
}' \
-f tid="PRRT_xxx"Reply template — pushback with evidence
A good pushback reply has four parts:
1. State the disagreement in one sentence. 2. Cite the primary source (link, with relevant quote). 3. Cite empirical evidence (CI run, test result, doc URL). 4. State what you are doing (leaving as-is / applying alt fix / etc.).
Example:
Thanks, but this suggestion is incorrect on both points and I am leaving the config as-is.
>
1. The field is `allowBuilds` and it is a map. pnpm 10.26 release notes define it as a map of package matchers to booleans, supporting per-version pinning (nx@21.6.4: true).>
2. `ignoredBuilds` is not a pnpm setting. The legacy field was ignoredBuiltDependencies, also removed in pnpm 11.>
3. Verified empirically. Thebuild-and-pushCI check is green on this PR with the current config (pnpm v11.0.9), noERR_PNPM_IGNORED_BUILDSwarning.
This pattern works for any bot reviewer. Three citations + a clear decision.
Reply template — partial agreement
When the reviewer raised a real concern but suggested a wrong fix:
Good catch on the underlying issue — fixed in <commit-sha>.
>
Going with<alternative>rather than<bot-suggestion>because <one-sentence reason with link>.
Anti-patterns
- Silently making the change to clear the review. Future maintainers can't tell whether the change was correct or compliance-driven.
- Top-level "thanks, addressed in commit X" comments. Lose the diff context. Always reply on the thread.
- "Disagree, see commit history." Not a reply. Cite docs and CI evidence.
- Leaving the thread unresolved with no reply. Reads as ignoring the bot. Reply, then resolve.
- Marking a thread resolved without replying when you applied the change. Drops the rationale; future readers see a closed thread with no record of the decision.
Per-bot quirks
Behavior is bot-specific and changes; treat as starting hints, verify against current behavior:
| Bot | Note |
|---|---|
gemini-code-assist[bot] | Often confidently invents config field names; severity badges (high/medium) are not always correlated with actual severity |
copilot-pull-request-reviewer[bot] | Tends toward verbose summaries; reviews almost never come back as APPROVED (state is COMMENTED); see auto-merge-guide.md for the auto-merge race condition |
coderabbitai[bot] | Higher signal but verbose; prone to repeating the same nit across many threads — resolving in batches is reasonable |
sourcery-ai[bot] | Stylistic / refactor focus; advice quality drops on non-mainstream language constructs |
Related
auto-merge-guide.md— Copilot-as-reviewer race condition that blocks PRs without leaving an actionable reviewmerge-strategy.md— for repos that require all threads resolved before merge
Auto-merge & Auto-approve Guide
Auto-merge for dependency bots and auto-approve for solo maintainers.
Solo Maintainer: Auto-approve via pr-quality.yml
Solo maintainer projects should keep required_approving_review_count >= 1 (required for OpenSSF Scorecard and good practice) and use a pr-quality.yml workflow that auto-approves PRs from repo collaborators.
How it works: The workflow checks the PR author's repository permission. If they have write or admin access, it approves the PR automatically via github-actions[bot], satisfying the review requirement without manual intervention.
Use the template: assets/pr-quality.yml.template → .github/workflows/pr-quality.yml
Branch protection settings:
gh api repos/OWNER/REPO/branches/main/protection/required_pull_request_reviews -X PATCH \
--input - << 'EOF'
{
"required_approving_review_count": 1,
"dismiss_stale_reviews_on_push": true,
"require_code_owner_reviews": false
}
EOFWho gets auto-approved:
| PR author | Approved by | Auto-merged by |
|---|---|---|
| Repo collaborator (write/admin) | pr-quality.yml | Manual merge or auto-merge rule |
| Dependabot / Renovate / release-please | auto-merge-deps.yml | auto-merge-deps.yml (via --auto) |
| External contributor | Manual review required | Manual merge |
Bootstrap note: When first adding pr-quality.yml, the PR that introduces it must be approved manually (the workflow isn't on the base branch yet). All subsequent PRs auto-approve.Troubleshooting Quick Reference
| Symptom | Cause | Fix |
|---|---|---|
| PR BLOCKED, checks pass | Check names don't match | Update branch protection to use exact names (e.g., job (variant) not job) |
PR BLOCKED, reviewDecision: REVIEW_REQUIRED | require_code_owner_reviews: true | Disable code owner reviews or add code owner approval |
| PR BLOCKED, unresolved threads | required_conversation_resolution: true | Resolve all review threads before merging |
| PR has pending reviewers | Requested reviewers haven't responded | Wait for all requested reviewers to submit their review |
| Renovate PR not using bypass | Workflow racing with Renovate | Only approve in workflow; let Renovate enable auto-merge via platformAutomerge |
| CI can't push to main | Branch protection blocks direct push | Use Renovate lockFileMaintenance instead |
| Workflow not triggering | Rapid merges skip push events | Add workflow_dispatch trigger, run manually |
| "Merge method X not allowed" | Wrong merge strategy | Use auto-detection (see below) or check gh api repos/O/R --jq '{merge: .allow_merge_commit, squash: .allow_squash_merge, rebase: .allow_rebase_merge}' |
| "Rebase merges cannot be automatically signed" | Signed commits + rebase | Enable squash merge on the repo; rebase merges cannot be auto-signed by GitHub |
| Bot detection misses reruns | github.actor changes on synchronize | Use github.event.pull_request.user.login instead of github.actor |
| Gitleaks fails on bot PRs | GITLEAKS_LICENSE secret unavailable | Skip gitleaks for bot PRs or use .gitleaks.toml allowlist |
| Old PRs not auto-merging | Opened before workflow existed | Comment @dependabot rebase / @renovate rebase to trigger synchronize |
| Can't merge workflow file PRs | GITHUB_TOKEN lacks workflows scope | Merge manually; use workflow check in auto-merge-direct.yml template |
Auto-approve skipped, PR stuck REVIEW_REQUIRED or blank reviewDecision | Auto-approve raced with Copilot reviewer | Re-run the auto-approve workflow after Copilot finishes; long-term fix is adding pull_request_review trigger — see Auto-Approve Race Condition with Copilot Reviewer |
Auto-Approve Race Condition with Copilot Reviewer
This section is the canonical home for the "PR stuck BLOCKED despite green CI + explicit approval" gotcha. Project-level CLAUDE.md entries should cross-reference this file rather than restating the details.
When using a solo-maintainer auto-approve workflow alongside GitHub Copilot as a reviewer, a race condition can leave PRs stuck blocked even though every gate appears satisfied:
1. New push triggers both the auto-approve workflow and Copilot review 2. Auto-approve runs first, sees Copilot as a pending reviewer, skips approval silently 3. Stale review dismissal (dismiss_stale_reviews_on_push: true) clears any previous approvals from the push 4. Copilot finishes reviewing with state COMMENTED (not APPROVED) — Copilot almost never actively approves 5. No approval exists anywhere; PR stays BLOCKED
Symptoms — gh pr view N --json mergeStateStatus,reviewDecision returns one of:
{"mergeStateStatus":"BLOCKED", "reviewDecision":"REVIEW_REQUIRED"}(classic case){"mergeStateStatus":"BLOCKED", "reviewDecision":""}(no review decision computed yet — seen when Copilot is mid-review and the repo hasrequired_approving_review_count >= 1)
In both cases: all CI status checks are SUCCESS, there are no CHANGES_REQUESTED reviews, no unresolved review threads, and the auto-approve job reports success in gh run list. The approval simply never happened.
Diagnosis
Confirm the silent-skip is the cause before re-running anything:
# 1. Find the latest auto-approve run for THIS PR's head commit and its job id.
# Scoping by head_sha prevents picking up runs from other PRs/branches.
HEAD_SHA=$(gh pr view PR_NUMBER --repo OWNER/REPO --json headRefOid --jq .headRefOid)
RUN_ID=$(gh api "repos/OWNER/REPO/actions/runs?head_sha=$HEAD_SHA&per_page=20" \
--jq '[.workflow_runs[] | select(.name == "PR Quality Gates")] | .[0].id')
JOB_ID=$(gh api "repos/OWNER/REPO/actions/runs/$RUN_ID/jobs" \
--jq '.jobs[] | select(.name | test("Auto-[Aa]pprove")) | .id' | head -1)
# 2. Inspect the job log for the skip marker. Use `gh run view --log` — the
# raw /logs API returns a zip archive that won't grep cleanly.
gh run view --log --job="$JOB_ID" --repo OWNER/REPO \
| grep -iE "skip|copilot|pending reviewer|requested_reviewers"If the log contains a line like "Skipping approval: pending reviewers" or shows requested_reviewers containing copilot-pull-request-reviewer[bot], the race condition is confirmed.
Also useful — current reviewer state:
gh api repos/OWNER/REPO/pulls/PR_NUMBER \
--jq '{requested_reviewers: [.requested_reviewers[]?.login], requested_teams: [.requested_teams[]?.slug]}'An empty requested_reviewers after Copilot's review has landed confirms Copilot is no longer blocking — so a rerun will now succeed.
Fix — re-run the workflow
# Scope the lookup to THIS PR's head commit — filtering only by workflow name
# can return runs from other PRs/branches.
HEAD_SHA=$(gh pr view PR_NUMBER --repo OWNER/REPO --json headRefOid --jq .headRefOid)
RUN_ID=$(gh api "repos/OWNER/REPO/actions/runs?head_sha=$HEAD_SHA&per_page=20" \
--jq '[.workflow_runs[] | select(.name == "PR Quality Gates")] | .[0].id')
# Re-run it
gh api repos/OWNER/REPO/actions/runs/$RUN_ID/rerun -X POSTWait ~2 minutes, then re-check gh pr view N --json mergeStateStatus,reviewDecision. Expected result: {"mergeStateStatus":"CLEAN", "reviewDecision":"APPROVED"}.
Why the rerun works:gh run rerunon the latest run re-reads the current reviewer list. By that point Copilot has submitted its review, so it's no longer inrequested_reviewersand auto-approve proceeds. See also CI Re-runs Replay the Same Commit — use the LATEST run id (not an older failed one) so the rerun executes against current HEAD.
Prevention
Pick one of these patterns when authoring or updating a pr-quality.yml / auto-approve workflow. See `../assets/pr-quality.yml.template` for the baseline.
Option A — also trigger on review submission (simplest):
on:
pull_request_target:
types: [opened, synchronize, reopened]
pull_request_review:
types: [submitted, dismissed]When Copilot submits its review, the workflow re-fires and now sees an empty requested_reviewers list. This is the lowest-effort fix and works for most solo-maintainer setups.
Option B — poll with retry inside the approval step (race-free, slower):
Before approving, poll requested_reviewers until it's empty or a timeout elapses. Typical timeout: 5 minutes. Use this when Copilot reviews are slow or when missed-approval events are expensive (e.g. repos with tight merge SLOs).
Option C — wait for Copilot explicitly:
Gate the approval step on github.event.review.user.login == 'copilot-pull-request-reviewer[bot]' && github.event.review.state != 'changes_requested' in a pull_request_review-triggered job. Race-free but fires only after Copilot posts — not useful when Copilot isn't actually assigned to the PR.
Do NOT "fix" this by dropping required_approving_review_count to 0 — that loses the OpenSSF Scorecard Code-Review point and removes the audit trail that shows a deliberate approval happened.
Post-Merge Review Sweep
Symptom: you admin-merge a PR that was CLEAN, then hours/days later gh api graphql shows unresolved review threads on it. The reviewer posted comments AFTER the merge landed.
Why this happens:
1. Admin-merging bypasses the "all threads resolved" check that a normal merge would enforce. A Copilot / human review that was still being authored at merge time lands afterwards. 2. Automated reviewers (GitHub Copilot, Advanced Security code-scanning, gosec/semgrep via reviewdog) often post on the final commit AFTER CI finishes — which can be after the merge. 3. In a batch workflow (e.g. closing out a multi-repo rollout), you move on to the next PR before the previous one settles.
Consequences: legitimate review findings go unanswered. If the finding was substantive (correctness bug, dead code, doc drift) it silently ships.
Sweep process — run at the end of any batched-merge session:
# List every PR you merged + check unresolved threads on each.
prs=(
"owner/repo1#123"
"owner/repo2#456"
...
)
for pr in "${prs[@]}"; do
R="${pr%#*}"; N="${pr#*#}"
OWNER="${R%/*}"; NAME="${R#*/}"
u=$(gh api graphql -f query="{
repository(owner: \"$OWNER\", name: \"$NAME\") {
pullRequest(number: $N) {
reviewThreads(first: 100) { nodes { isResolved } }
}
}
}" | jq '[.data.repository.pullRequest.reviewThreads.nodes[]
| select(.isResolved == false)] | length')
[ "$u" -gt 0 ] && echo "UNRESOLVED: $pr ($u)"
donereviewThreads(first: 100) covers GitHub's per-page maximum. Paginate with pageInfo { hasNextPage endCursor } + after: for PRs that might exceed 100 threads (long-running PRs on hot files).
For each unresolved thread:
1. Read the initial comment body via reviewThreads(first: 100) { nodes { id isResolved comments(first: 1) { nodes { author { login } body } } } }. Bump comments(first:) or paginate if you need follow-up replies on the same thread rather than just the initiating comment. 2. If valid → open a follow-up PR that addresses it, referencing the PR + thread by URL in the commit message. 3. Reply on the thread using the GraphQL mutations in `gh-cli-reference.md`: addPullRequestReviewThreadReply + resolveReviewThread. 4. If not valid (false positive, design-intent) → reply with the reasoning (cite evidence — tested behavior, design docs, etc.) then resolve.
Don't silently dismiss review threads. Even for false positives, leave a reply explaining why so the next person who opens the PR sees the decision.
Re-sweep after follow-up PRs merge. Copilot often reviews the follow-up PR itself and posts new threads. The sweep isn't one-shot — run it again until the count hits zero across all touched PRs.
Wait for Copilot Before Merging (prevents the cascade)
Copilot's review is asynchronous: it usually lands 1–3 min after the PR opens, sometimes longer on a busy day. If you enable --auto --merge the instant CI passes, you merge before Copilot has reviewed — and the review lands on an already-merged PR, which then needs a follow-up PR to address. Copilot reviews that follow-up too, so the same race repeats. A single round of non-trivial review can easily cascade to 5–6 follow-up PRs.
Prevention — poll for Copilot before enabling auto-merge:
# Wait up to 5 min for Copilot's review to appear. If it never does
# (skill-only docs PRs, repos without Copilot review enabled), the loop
# exits on timeout and you proceed.
for _ in $(seq 1 30); do
reviewed=$(gh pr view "$PR" --repo "$REPO" --json reviews --jq '
[.reviews[] | select(.author.login == "copilot-pull-request-reviewer")] | length')
[ "$reviewed" -ge 1 ] && break
sleep 10
done
# Now address any unresolved threads, THEN enable auto-merge.
gh pr merge "$PR" --repo "$REPO" --auto --mergeOr enforce via branch protection: add copilot-pull-request-reviewer as a required reviewer so branch protection blocks merge until the review exists. GitHub's UI for this is under Settings → Branches → Branch protection rules → Require review from Code Owners / specific reviewers; for rulesets, set required_pull_request_reviews.required_approving_review_count >= 1 with dismiss_stale_reviews_on_push: true. Note that requested isn't the same as approved — see merge-strategy.md on blocking on pending reviews.
If you still cascade, expect it: budget 2–3 sweep rounds mentally rather than claiming "done" after the first merge. The Copilot-review → fix → merge → Copilot-reviews-the-fix loop is the norm on non-trivial text changes, not the exception.
Validate Copilot Suggestions Before Applying
Copilot occasionally suggests syntactically invalid or semantically wrong code. Recent examples from this fleet:
- `?:` ternary in a GitHub Actions expression — not supported; GHA expressions use
&&/||chains - `inputs.make_latest` in a workflow that triggers on both
push.tagsandworkflow_dispatch— theinputscontext is undefined onpushevents and raises "Unrecognized named-value: 'inputs'"; usegithub.event.inputs.*for safety across events
Treat suggestions as reviewer input, not ground truth. Read the code, verify against docs (see workflow-bash-patterns.md for the GHA expression specifics), and apply in the form that's actually correct. Reply with your adjusted reasoning on the thread rather than silently applying a broken suggestion and having to patch it two sweeps later.
CI Annotations — Always Check Before Declaring a PR Clean
CI checks can report success at the status-run level while still emitting warning annotations (typical for actionlint / shellcheck via reviewdog, CodeQL deprecation notices, YAML-lint). These annotations don't show up in gh pr checks or in the PR summary page — they only appear on the job's detail page or on the Files-Changed tab. Declaring a PR "clean" based on gh pr checks alone leaves real findings un-addressed.
Check explicitly:
# Annotations on a specific check run:
gh api repos/OWNER/REPO/check-runs/CHECK_RUN_ID/annotations --jq \
'.[] | {message, annotation_level, path, start_line}'
# All check runs for a commit that have any annotations:
gh api "repos/OWNER/REPO/commits/SHA/check-runs" --jq \
'.check_runs[] | select(.output.annotations_count > 0) |
{name, annotations: .output.annotations_count}'Make warnings blocking. reviewdog-based linters default to posting warnings that don't fail the workflow. Configure them to fail:
- uses: reviewdog/action-actionlint@v1 # or -shellcheck, -yamllint, etc.
with:
fail_level: errorfail_level: error is the modern input; the deprecated fail_on_error + level combination still works but is going away. When a new reviewdog-based linter is added, grep the caller for fail_level: and set it to error up front — otherwise real findings silently accumulate.
CI Re-runs Replay the Same Commit
gh run rerun <run-id> re-executes the ORIGINAL commit SHA, not HEAD. If you push a fix and re-run a failed old workflow, the rerun still fails against the pre-fix code.
Right way: push the fix, then either wait for the automatic run triggered by the push, or re-run the LATEST run:
# Latest run ID for a workflow on a branch:
gh api "repos/OWNER/REPO/actions/runs?per_page=5" --jq \
'.workflow_runs[] | select(.name == "CI") | {id, head_sha: .head_sha[:7]}' \
| head -1
gh api repos/OWNER/REPO/actions/runs/RUN_ID/rerun -X POSTMerge Queue Behavior and Pitfalls
Sequential Processing
GitHub's merge queue processes PRs one at a time:
- Full CI runs for each PR before it merges
- Force-pushing a queued PR re-triggers CI from scratch and resets its position
- When the preceding PR merges, queued PRs behind it are effectively rebased behind — they must be rebased on the new
main, force-pushed, and re-queued
Force Push + Stale Review Dismissal Interaction
Rebasing before re-queuing triggers stale review dismissal, which cascades into an auto-approve requirement:
1. You force-push the rebased branch 2. dismiss_stale_reviews_on_push: true clears the previous approval 3. Auto-approve workflow must re-run to create a fresh approval 4. Old review threads survive force push — threads created against now-obsolete commits are still tracked by GitHub's conversation resolution requirement and still block merge
Explicitly resolve stale threads via GraphQL before re-queuing:
# Find thread IDs
gh api repos/OWNER/REPO/pulls/NUMBER/comments --jq '.[] | {id, node_id, body}'
# Resolve each thread
gh api graphql -f query='mutation { resolveReviewThread(input: {threadId: "PRRT_xxx"}) { thread { isResolved } } }'Multi-PR Workflow Pattern
When landing multiple dependent PRs, expect the dependent PRs to need rebasing after each merge:
1. Queue PR1 and PR2 (PR2 depends on PR1)
2. PR1 merges
3. PR2 is now behind — must be rebased:
git fetch origin
git rebase origin/main
git push --force-with-lease
4. Force push dismisses approval → wait for auto-approve to re-run (or re-trigger it)
5. Resolve any stale review threads from old commits
6. Re-queue PR2:
gh pr merge NUMBER --merge --autoChecklist before re-queuing after rebase:
| Step | Command |
|---|---|
| Rebase on latest main | git rebase origin/main && git push --force-with-lease |
| Trigger auto-approve | Wait for pr-quality.yml to run, or re-run it manually |
| Resolve stale threads | GraphQL resolveReviewThread for each stale thread |
| Re-enable auto-merge | gh pr merge NUMBER --merge --auto |
Troubleshooting Merge Queue Issues
| Symptom | Cause | Fix |
|---|---|---|
| PR exits queue after force push | CI reset on new push | Expected — wait for CI to pass again |
| PR stuck after preceding PR merged | PR is now behind main | Rebase, force push, re-queue |
REVIEW_REQUIRED after rebase | Stale review dismissal cleared approval | Re-run auto-approve workflow |
| Unresolved threads block merge | Old threads survive rebase | Resolve via GraphQL resolveReviewThread |
Verifying a PR Actually Merged (enqueue ≠ merged)
On a merge-queue repo, gh pr merge … --merge (or --auto) only enqueues the PR — the command returns success immediately and the PR is still open. Don't report "merged" off the exit code; the queue runs its own CI first and merges later (minutes, if the queue runs the full matrix). It can also silently stall.
The queue runs CI on a synthetic branch named gh-readonly-queue/<base>/pr-<N>-<sha>. To find that CI and confirm the PR lands:
# Is it queued, and at what position?
gh api graphql -f query='query($owner:String!,$repo:String!){
repository(owner:$owner,name:$repo){
mergeQueue { entries(first:10){ nodes{ position state pullRequest{ number } } } } } }' \
-f owner=OWNER -f repo=REPO \
--jq '.data.repository.mergeQueue?.entries?.nodes[]? // empty'
# Watch the queue's own CI (note the merge_group event, not pull_request)
gh run list --repo OWNER/REPO --event merge_group --limit 5 \
--json status,conclusion,headBranch,name \
--jq '.[] | select(.headBranch|test("pr-<N>-")) | "\(.status)/\(.conclusion // "-") \(.name)"'
# Confirm it actually merged (state MERGED + branch gone)
gh pr view <N> --repo OWNER/REPO --json state,mergedAt,mergeCommit \
--jq '{state, mergedAt, mergeCommit: (.mergeCommit?.oid // "none")}'Poll state == "MERGED" (or a merge_group run concluding failure) before declaring done — green checks on the PR head are necessary but not sufficient once a queue is in play.
Signed Commits and Merge Strategy Compatibility
GitHub can only auto-sign merge commits and squash merges. It cannot auto-sign rebased commits. If branch protection requires signed commits and the workflow uses --rebase, merges fail with:
Base branch requires signed commits. Rebase merges cannot be automatically signed by GitHub.Auto-detect Merge Strategy
Instead of hardcoding --merge, --squash, or --rebase, auto-detect from repo settings:
STRATEGY=$(gh api "repos/${{ github.repository }}" --jq '
if .allow_squash_merge then "--squash"
elif .allow_merge_commit then "--merge"
elif .allow_rebase_merge then "--rebase"
else "--squash" end')
gh pr merge --auto $STRATEGY "$PR_URL"Priority order: squash > merge > rebase. Squash is preferred because: 1. Works with signed commit requirements (GitHub can sign squash merges) 2. Clean history for single-commit dependency PRs 3. Most universally compatible
Enabling Squash Merge on Repos
If a repo only allows rebase merges and requires signed commits, enable squash:
gh api repos/OWNER/REPO -X PATCH -f allow_squash_merge=trueWorkflow File Changes Cannot Be Auto-merged
PRs that modify .github/workflows/ files cannot be merged by GITHUB_TOKEN — it lacks the workflows permission scope. This commonly affects Dependabot/Renovate PRs that update GitHub Actions versions.
Detection: The auto-merge-deps.yml workflow should check for workflow file changes before attempting merge:
WORKFLOW_FILES=$(gh pr diff "$PR_URL" --name-only | grep -E '^\.github/workflows/' || true)
if [ -n "$WORKFLOW_FILES" ]; then
echo "PR modifies workflow files — requires manual merge"
fiResolution: Merge manually using a local clone with SSH authentication:
# For repos without branch protection (direct push allowed):
git clone --depth=5 git@github.com:OWNER/REPO.git /tmp/REPO
cd /tmp/REPO
BRANCH=$(gh pr view NUMBER --repo OWNER/REPO --json headRefName --jq '.headRefName')
git fetch origin "$BRANCH"
git merge --no-ff -S --signoff "origin/$BRANCH" -m "Merge pull request #NUMBER from OWNER/$BRANCH"
git push origin MAIN_BRANCHFor repos with multiple workflow PRs: Merge sequentially — each subsequent PR may need rebasing after the previous one merges since they typically touch the same workflow files.
Batch Auto-merge for Multiple PRs
When enabling auto-merge across many repos/PRs at once:
gh pr merge NUMBER --repo OWNER/REPO --auto --mergeLimitations discovered:
- GitHub may reject auto-merge on a second PR in the same repo if the first is still pending: "Pull request Auto merge is not allowed for this repository"
- This is typically a timing issue — once the first PR merges, re-enable auto-merge on remaining PRs
- Some repos need the "Allow auto-merge" setting enabled in repository settings first
- Repos where you lack admin/maintainer access will fail with permission errors
Archived Repos — Handle Dependabot/Renovate PRs with Care
Archived repositories still receive Dependabot/Renovate PRs, but you cannot merge them or enable auto-merge — writes are blocked, and auto-merge fails with a permission error. Don't fight it:
1. Unarchive the repo. 2. Close the PR (or merge it, if the update is genuinely wanted). 3. Re-archive the repo.
Before any batch auto-merge sweep across an org, filter archived repos out first so they don't generate noise:
# gh filters archived repos out server-side:
gh repo list ORG --no-archived --json name --jq '.[].name'Never enable auto-merge on an archived repo — it errors and leaves the PR in a confusing half-state.
Recommended Renovate Config for Auto-merge
{
"extends": ["config:recommended"],
"automergeType": "pr",
"platformAutomerge": true,
"lockFileMaintenance": {
"enabled": true,
"schedule": ["before 6am on monday"]
},
"packageRules": [
{
"matchUpdateTypes": ["patch", "minor", "pin", "digest"],
"automerge": true
}
]
}Key settings:
platformAutomerge: true- Renovate enables auto-merge (uses bypass permissions)lockFileMaintenance- Handles lock file updates via PR (not direct push)
Canonical Auto-merge Workflow Template
name: Auto-merge dependency PRs
on:
pull_request_target:
types: [opened, synchronize, reopened]
permissions:
contents: write
pull-requests: write
jobs:
auto-merge:
runs-on: ubuntu-latest
if: >-
github.event.pull_request.user.login == 'dependabot[bot]' ||
github.event.pull_request.user.login == 'renovate[bot]'
steps:
- name: Approve PR
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh pr review --approve "$PR_URL"
- name: Enable auto-merge
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
run: |
STRATEGY=$(gh api "repos/$REPO" --jq '
if .allow_squash_merge then "--squash"
elif .allow_merge_commit then "--merge"
elif .allow_rebase_merge then "--rebase"
else "--merge" end')
gh pr merge --auto "$STRATEGY" "$PR_URL"Key Design Decisions
- `pull_request_target`: Required for bot PRs —
pull_requestruns with read-only tokens for fork-like contexts - `user.login`: Immutable PR author field —
github.actorchanges when humans re-run workflows - `--auto`: Respects branch protection, merge queues, and required checks — direct merge bypasses these
- Dynamic strategy: Repos may only allow specific merge methods — hardcoding breaks when config changes
Branch Protection for Auto-merge
# Check required checks vs actual check names
gh api repos/OWNER/REPO/branches/main/protection/required_status_checks --jq '.checks[].context'
# Check code owner requirement (should be false for auto-merge)
gh api repos/OWNER/REPO/branches/main/protection/required_pull_request_reviews --jq '.require_code_owner_reviews'
# Check bypass apps
gh api repos/OWNER/REPO/branches/main/protection/required_pull_request_reviews --jq '.bypass_pull_request_allowances.apps[].slug'
# Fix: Disable code owner reviews, add bypass apps
gh api repos/OWNER/REPO/branches/main/protection/required_pull_request_reviews -X PATCH \
--input - << 'EOF'
{
"require_code_owner_reviews": false,
"required_approving_review_count": 1,
"bypass_pull_request_allowances": {
"apps": ["dependabot", "renovate"]
}
}
EOFBranch Migration Reference
Guide for migrating from master to main as default branch.
Migration Steps
Step 1: Rename locally and push
# Rename local branch
git branch -m master main
# Push new branch to remote
git push -u origin mainStep 2: Update GitHub default branch
# Set main as default (via API)
gh api repos/{owner}/{repo} --method PATCH -f default_branch=main
# Or via gh repo edit
gh repo edit --default-branch mainStep 3: Update branch protection
# Copy protection rules from master to main (if any existed)
# Then delete master protection
gh api repos/{owner}/{repo}/branches/master/protection --method DELETE 2>/dev/null || true
# Set up protection on main (see Branch Protection Configuration in SKILL.md)Step 4: Delete old master branch
# Delete remote master
git push origin --delete masterStep 5: Prevent master from being re-created
Create a branch protection rule for master that blocks all pushes:
# Create restrictive rule for "master" branch name
gh api repos/{owner}/{repo}/branches/master/protection \
--method PUT \
-f required_status_checks=null \
-f enforce_admins=true \
-f required_pull_request_reviews='{"required_approving_review_count":6,"dismiss_stale_reviews":true}' \
-f restrictions='{"users":[],"teams":[]}' \
-f allow_force_pushes=false \
-f allow_deletions=falseThis creates a "ghost" protection rule that:
- Requires 6 approvals (effectively blocking all PRs)
- Restricts pushes to nobody
- Prevents the branch from being created
Step 6: Update CI/CD workflows
# Find and update workflow files
grep -rl "master" .github/workflows/ | xargs sed -i 's/master/main/g'
# Common patterns to update:
# - branches: [master] → branches: [main]
# - on: push: branches: master → main
# - refs/heads/master → refs/heads/mainStep 7: Update documentation
Search and replace branch references:
# Find all references to master branch in docs
grep -rn "master" --include="*.md" --include="*.rst" --include="*.txt"| File | Pattern | Update to |
|---|---|---|
| README.md | badge/branch-master | badge/branch-main |
| README.md | github.com/org/repo/tree/master | tree/main |
| README.md | github.com/org/repo/blob/master | blob/main |
| README.md | ?branch=master | ?branch=main |
| CONTRIBUTING.md | "merge into master" | "merge into main" |
| docs/*.md | /master/ links | /main/ |
| package.json | "repository": "...#master" | #main |
| composer.json | "dev-master" or #master | "dev-main" or #main |
# Bulk update in markdown files
find . -name "*.md" -exec sed -i 's|/master/|/main/|g; s|/master"|/main"|g; s|branch-master|branch-main|g' {} \;Step 8: Notify team
Team members must update local repos:
git checkout master
git branch -m master main
git fetch origin
git branch -u origin/main main
git remote set-head origin -a