GitHub Actions Automation skill for Codex

Repo housekeeping bots with least privilege. Codex writes housekeeping workflows for labels, stale issues, greetings, triage, Dependabot auto-merge and release notes, each with explicit per-job permissions and a manual dry-run trigger. It keeps pull_request_target away from fork code, routes untrusted titles through env or github-script instead of shell, and explains why GITHUB_TOKEN events never start a second workflow.

  • Automation & Scripting
  • $github-actions-automation
  • 145 lines
  • 3 files
  • ~1,850 tokens
  • MIT
  • Updated
  • Editor's pick

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- github-actions-automation

Installs to ~/.agents/skills/github-actions-automation/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $github-actions-automation, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Automates GitHub repository chores with Actions - PR labeling by path, stale issue and PR cleanup, first-time contributor greetings, issue triage with actions/github-script, Dependabot auto-merge, release drafting with release-drafter or release-please - using least-privilege permissions and safe pull_request_target patterns. Use when the user wants to auto-label PRs, close stale issues, auto-merge Dependabot patches, draft release notes, triage new issues, or a bot workflow fails with 403 or never triggers.

Use it when

  • Maintainers spend time labeling, triaging or closing stale items by hand
  • Dependabot PRs pile up and safe updates should merge themselves
  • Release notes should be drafted from merged PRs
  • A bot workflow fails with 403 or never triggers

Use something else when

How to check the result

actionlint passes, a workflow_dispatch dry run lists a plausible set of issues or PRs, and a test PR from a fork gets labeled and greeted under pull_request_target.

The mistake it prevents

Checking out and running fork code inside a pull_request_target job, which hands the fork a write token and repository secrets.

~/.agents/skills/github-actions-automation/SKILL.md

--- name: github-actions-automation description: Automates GitHub repository chores with Actions - PR labeling by path, stale issue and PR cleanup, first-time contributor greetings, issue triage… license: MIT ---

Done well means: each chore runs with only the permissions it needs, cannot be hijacked by a fork’s code, does not spam contributors, and a maintainer can preview its effect before it touches hundreds of issues. This skill is about housekeeping bots; build and test pipelines belong to $github-actions-workflows.

Inspect first

ls .github/workflows .github/*.yml 2>/dev/null
gh api repos/{owner}/{repo} --jq '{default_branch, allow_auto_merge, delete_branch_on_merge}'
gh label list --limit 200                       # labels the automation will use
gh api repos/{owner}/{repo}/actions/permissions/workflow   # default GITHUB_TOKEN scope

Read AGENTS.md and CONTRIBUTING.md for label names and release conventions. Reuse existing labels; creating a parallel taxonomy confuses triage. gh calls need network and auth; in an offline sandbox, write the files and list the commands for the user.

Pick the chore’s tool

ChoreDefaultTrigger
Label PRs by changed pathsactions/labeler@v5 + .github/labeler.ymlpull_request_target
Label by PR size, title or bodyactions/github-script@v7pull_request_target
Mark and close stale issues/PRsactions/stale@v9schedule + workflow_dispatch
Lock old closed threadsdessant/lock-threads@v5schedule
Greet first-time contributorsactions/github-script@v7pull_request_target, issues
Auto-merge Dependabot patch/minordependabot/fetch-metadata@v2 + gh pr merge --autopull_request
Draft release notes from merged PRsrelease-drafter/release-drafter@v6push to default branch
Versioned releases from Conventional Commitsgoogleapis/release-please-action@v4push to default branch
Anything custom with the REST APIactions/github-script@v7as needed

Full, copyable workflow files for each row are in references/recipes.md. Read it when writing one of these workflows; adapt labels and thresholds to the repo.

Hard rules

  1. Explicit permissions on every workflow. Start from permissions: {} at the top and grant per job: issues: write, pull-requests: write, contents: write only for release jobs.
  2. pull_request_target never checks out or runs the PR’s code. It runs with a write token and secrets in the base repo context. Labeler and greeting jobs only read metadata through the API, which is safe. If a job must build PR code, use pull_request (read-only token for forks).
  3. Do not interpolate untrusted text into run:. ${{ github.event.issue.title }} inside a shell script is command injection. Pass it through env: and quote it, or read it inside github-script from context.payload.
  4. Pin actions. Use a major tag from a trusted publisher at minimum; pin third-party actions to a full commit SHA with a comment naming the version.
  5. Dry-run bulk changes. actions/stale has debug-only: true; custom scripts get a dry_run input on workflow_dispatch. Run once in dry-run and read the log before the first real run touches existing issues.

Events and tokens: why a bot “never triggers”

  • Events created with the default GITHUB_TOKEN (a label added, a PR opened, a release published) do not start other workflows, except workflow_dispatch and repository_dispatch. To chain workflows, use a GitHub App token (actions/create-github-app-token) or trigger the second workflow explicitly.
  • schedule runs only on the default branch, in UTC, may start late, and is disabled after 60 days without repository activity in public repos. Always add workflow_dispatch.
  • Dependabot-triggered pull_request runs get a read-only token and only Dependabot secrets; grant write permissions explicitly in the workflow.
  • 403 Resource not accessible by integration means the job lacks a permission scope or the org restricts the default token. Add the scope; do not swap in a personal token.

github-script essentials

- uses: actions/github-script@v7
  with:
    script: |
      const { owner, repo } = context.repo;
      const pr = context.payload.pull_request;
      const changed = pr.additions + pr.deletions;
      const size = changed < 50 ? 'size/S' : changed < 400 ? 'size/M' : 'size/L';
      const stale = pr.labels.map(l => l.name).filter(n => n.startsWith('size/') && n !== size);
      for (const name of stale) {
        await github.rest.issues.removeLabel({ owner, repo, issue_number: pr.number, name });
      }
      await github.rest.issues.addLabels({ owner, repo, issue_number: pr.number, labels: [size] });
  • github is an authenticated Octokit; context holds the event. PRs are issues for labels and comments (issues.* endpoints with the PR number).
  • Paginate with github.paginate(github.rest.issues.listForRepo, {...}) when scanning.
  • Add labels only if they exist, or create them first; addLabels silently creates labels with default grey colour.

Workflow for adding a chore

- [ ] 1. Confirm labels, thresholds and messages with the maintainer
- [ ] 2. Write the workflow with explicit permissions and workflow_dispatch
- [ ] 3. Validate syntax: actionlint (if installed) and yamllint
- [ ] 4. Push to a branch; run in dry-run via workflow_dispatch where supported
- [ ] 5. Read the run log; check the planned changes match intent
- [ ] 6. Merge; watch the first scheduled or event-triggered run

Workflows that comment on, close, lock or merge items act publicly on the user’s repository. Show the user the messages and thresholds and get a yes before enabling them on the default branch.

Gotchas

  • actions/stale processes a limited number of API operations per run (operations-per-run, default 30); a large backlog takes several runs. Raise it gradually and watch rate limits.
  • Stale bots on a project with slow maintainers alienate contributors. Exempt labels like confirmed, security and pinned, and set a long days-before-stale (60 to 90).
  • gh pr merge --auto requires “Allow auto-merge” in repo settings and branch protection with required checks; otherwise it merges immediately or fails.
  • Release jobs need contents: write; release-please also needs pull-requests: write. Org settings may forbid Actions from creating PRs (“Allow GitHub Actions to create and approve pull requests”).
  • Labeler v5 changed the config format (changed-files with any-glob-to-any-file); v4-style configs fail with a parse error.

Verification

  • actionlint passes (it also catches untrusted-input injection in run:).
  • Dry-run log lists the issues or PRs it would touch, and the count is plausible.
  • Open a test PR from a fork or a second account to confirm labeler and greeting work under pull_request_target.
  • For release automation, confirm the draft or release PR content on a branch merge before the first tag is published.

Summary for the maintainer

**Added:** .github/workflows/stale.yml, .github/workflows/labeler.yml, .github/labeler.yml
**Permissions:** stale: issues/pull-requests write; labeler: pull-requests write, contents read
**Behaviour:** issues stale after 90 days, closed 14 days later; exempt: confirmed, security
**Dry run:** stale debug-only run on 2026-10-28 would mark 41 issues, close 0
**To disable:** delete the workflow file or `gh workflow disable stale.yml`

What the zip contains

Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "GitHub Actions Automation"
  short_description: "Repo housekeeping bots with least privilege"
  default_prompt: "$github-actions-automation Auto-label PRs by folder and close issues with no activity for 90 days"
references/recipes.md259 lines, 6.5 KB
# Repo chore recipes

Copy, then adjust labels, thresholds and messages to the repository. Every recipe has
explicit permissions and a manual trigger where it makes sense.

## Path labels (labeler v5)

`.github/labeler.yml`:

```yaml
area/docs:
  - changed-files:
      - any-glob-to-any-file: ["docs/**", "**/*.md"]
area/api:
  - changed-files:
      - any-glob-to-any-file: ["src/api/**", "openapi/**"]
dependencies:
  - changed-files:
      - any-glob-to-any-file: ["package.json", "pnpm-lock.yaml", "requirements*.txt"]
```

`.github/workflows/labeler.yml`:

```yaml
name: Label PRs
on:
  pull_request_target:
    types: [opened, synchronize, reopened]
permissions: {}
jobs:
  label:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: write
    steps:
      - uses: actions/labeler@v5
        with:
          sync-labels: true        # remove path labels that no longer apply
```

No checkout step: labeler reads the changed-file list through the API.

## Stale issues and PRs

```yaml
name: Stale
on:
  schedule:
    - cron: "27 3 * * *"
  workflow_dispatch:
    inputs:
      debug:
        description: "Dry run (log only)"
        type: boolean
        default: true
permissions: {}
jobs:
  stale:
    runs-on: ubuntu-latest
    permissions:
      issues: write
      pull-requests: write
    steps:
      - uses: actions/stale@v9
        with:
          debug-only: ${{ github.event_name == 'workflow_dispatch' && inputs.debug }}
          days-before-stale: 90
          days-before-close: 14
          stale-issue-label: stale
          stale-pr-label: stale
          exempt-issue-labels: confirmed,security,pinned
          exempt-pr-labels: security,work-in-progress
          exempt-draft-pr: true
          stale-issue-message: >
            This issue has had no activity for 90 days. It will close in 14 days
            unless someone comments or a maintainer adds the `confirmed` label.
          close-issue-message: Closed for inactivity. Comment if it is still relevant and we will reopen.
          stale-pr-message: >
            This PR has had no activity for 90 days. Push a commit or comment to keep it open.
          operations-per-run: 60
```

## First-time contributor greeting

```yaml
name: Greet
on:
  pull_request_target:
    types: [opened]
permissions: {}
jobs:
  greet:
    runs-on: ubuntu-latest
    permissions:
      pull-requests: write
    steps:
      - uses: actions/github-script@v7
        with:
          script: |
            const pr = context.payload.pull_request;
            const assoc = pr.author_association;
            if (!['FIRST_TIME_CONTRIBUTOR', 'FIRST_TIMER'].includes(assoc)) return;
            await github.rest.issues.createComment({
              ...context.repo,
              issue_number: pr.number,
              body: [
                `Thanks for your first PR, @${pr.user.login}.`,
                'A maintainer will review within a week. CONTRIBUTING.md lists the checks CI runs.',
              ].join('\n\n'),
            });
```

## Issue triage: require a reproduction

```yaml
name: Triage new issues
on:
  issues:
    types: [opened]
permissions: {}
jobs:
  triage:
    runs-on: ubuntu-latest
    permissions:
      issues: write
    steps:
      - uses: actions/github-script@v7
        with:
          script: |
            const issue = context.payload.issue;
            const body = issue.body || '';
            const labels = ['needs-triage'];
            if (/^### Steps to reproduce\s*\n+\s*(_No response_)?\s*$/m.test(body) || !/Steps to reproduce/i.test(body)) {
              labels.push('needs-repro');
            }
            await github.rest.issues.addLabels({ ...context.repo, issue_number: issue.number, labels });
```

The issue title and body are untrusted input: they are read inside JavaScript here, never
interpolated into a shell `run:` line.

## Dependabot auto-merge (patch and minor)

```yaml
name: Dependabot auto-merge
on: pull_request
permissions: {}
jobs:
  automerge:
    if: github.event.pull_request.user.login == 'dependabot[bot]'
    runs-on: ubuntu-latest
    permissions:
      contents: write
      pull-requests: write
    steps:
      - id: meta
        uses: dependabot/fetch-metadata@v2
      - if: steps.meta.outputs.update-type != 'version-update:semver-major'
        run: gh pr merge --auto --squash "$PR_URL"
        env:
          PR_URL: ${{ github.event.pull_request.html_url }}
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
```

Requires "Allow auto-merge" and branch protection with required status checks, so the
merge waits for CI. Major versions stay manual.

## Release notes draft (release-drafter)

`.github/release-drafter.yml`:

```yaml
name-template: "v$RESOLVED_VERSION"
tag-template: "v$RESOLVED_VERSION"
categories:
  - title: Features
    labels: [feature, enhancement]
  - title: Fixes
    labels: [bug, fix]
  - title: Maintenance
    labels: [chore, dependencies]
version-resolver:
  major: { labels: [breaking] }
  minor: { labels: [feature, enhancement] }
  default: patch
template: |
  $CHANGES
```

```yaml
name: Release drafter
on:
  push:
    branches: [main]
  workflow_dispatch:
permissions: {}
jobs:
  draft:
    runs-on: ubuntu-latest
    permissions:
      contents: write
      pull-requests: read
    steps:
      - uses: release-drafter/release-drafter@v6
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
```

The draft is never published automatically; a maintainer edits and publishes it.

## Release PRs from Conventional Commits (release-please)

```yaml
name: release-please
on:
  push:
    branches: [main]
permissions: {}
jobs:
  release:
    runs-on: ubuntu-latest
    permissions:
      contents: write
      pull-requests: write
    steps:
      - uses: googleapis/release-please-action@v4
        with:
          release-type: node     # or python, go, simple; or use a manifest config file
```

Merging the release PR creates the tag and GitHub release. A publish job (npm, PyPI) that
should run on that release must be triggered by the action's `release_created` output in
the same workflow, because a release created by `GITHUB_TOKEN` does not trigger other
workflows.

## Lock old closed threads

```yaml
name: Lock threads
on:
  schedule:
    - cron: "41 4 * * 1"
  workflow_dispatch:
permissions: {}
jobs:
  lock:
    runs-on: ubuntu-latest
    permissions:
      issues: write
      pull-requests: write
    steps:
      - uses: dessant/lock-threads@v5
        with:
          issue-inactive-days: 180
          pr-inactive-days: 180
          issue-comment: >
            Locked because it has been closed for 180 days. Open a new issue and link this one.
```

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem