Open-Source Project on GitHub
You published a small utility library to a public repo, wrote a decent README, and forgot about it. Eight months later it has 4,000 stars, a dependency footprint you can no longer see the edges of, and an issue tracker that fills faster than you can read it. Strangers open PRs from forks, file bugs without a version number, and ask in the issues why their unrelated framework is broken. The code is the same size it always was; the project around it has become a second job.
The scenario forces a set of decisions that have nothing to do with writing code: how untrusted contributions run in CI, how to keep the issue tracker legible, how to cut releases that downstream consumers can trust, and how to do all of it without burning out. Each decision has a wrong default that looks convenient and turns out to be a liability — the sharpest one being a CI trigger that can hand a stranger your secrets.
Triage Before Code
The first thing that breaks at scale is the inbox. A 600-item tracker where every report says "doesn't work" is indistinguishable from no tracker at all. Issue forms (.github/ISSUE_TEMPLATE/*.yml) fix this at the source by making version, environment, and a reproduction required fields — a report that cannot be filed without a repro is a report you can act on. Labels turn the remaining pile into a queue: bug, needs-repro, and good first issue let you and contributors sort by what is actionable rather than reading top to bottom.
The triage decision that pays off later is keeping a handful of good first issue tickets open at all times. They are the only realistic on-ramp for new contributors, and a project with none of them quietly concentrates all work on the maintainer.
The Permission Model for Fork PRs
A pull request from a fork runs in a fundamentally different trust context than one from a branch on your repo. By design, a pull_request workflow triggered by a fork gets a read-only GITHUB_TOKEN and no access to your repository secrets. That is not a limitation to work around — it is the security boundary that lets you run a stranger's code at all. The contributor's tests run, the build runs, but nothing the workflow does can push to your repo or read a deploy key.
A typical PR-gate workflow stays inside that boundary deliberately:
name: CI
on: pull_request
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm test
The pull_request_target Trap
GitHub offers a second trigger, pull_request_target, that runs in the context of the base repository — with a read-write token and access to secrets. It exists for legitimate jobs like labeling a PR or posting a welcome comment, where you need write access but never touch the contributor's code. The danger is checking out the PR's head under it:
# DANGER: do not do this
on: pull_request_target
jobs:
build:
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }} # attacker code
- run: npm ci && npm run build # runs with your secrets
That combination runs arbitrary attacker-supplied code with a write token and your secrets in scope — a documented, repeatedly-exploited path to exfiltrating credentials and pushing to the base repo. For anything that builds or executes contributor code, the answer is plain pull_request, and any job that genuinely needs a secret goes behind an environment approval gate so a human signs off before the secret is exposed.
Releases and Downstream Trust
Once people depend on the library, releases become a contract. Tag-driven releases — push v2.3.0, an Action builds the artifact, creates a GitHub Release with generated notes, and attaches the build output — give downstream consumers a stable, signed point to pin to. The version number itself is a signal: SemVer tells a consumer whether an upgrade is safe to take automatically, and breaking that promise once teaches everyone to pin exact versions, which defeats the point.
Community Infrastructure
Three files carry most of the weight. CONTRIBUTING.md sets expectations so PRs arrive shaped correctly; CODE_OF_CONDUCT.md gives you grounds to moderate; and SECURITY.md is the one that prevents real damage — it points vulnerability reporters at a private channel (GitHub Security Advisories or an email) instead of a public issue. Without it, the default behavior of a well-meaning reporter is to disclose the exploit publicly before a patch exists. Routing questions to Discussions rather than Issues keeps the actionable queue clean.
Maintainer Load
The project outlives your enthusiasm only if the load is bounded. A documented scope ("we do not accept X") lets you close out-of-scope requests without a debate each time. A stale-issue bot auto-closes abandoned reports so the tracker reflects reality. And declining to promise a response SLA you cannot hold is not rudeness — it is the difference between a sustainable project and an abandoned one.
pull_request — runs in the context of the merge, with a read-only token and no secrets for fork PRs. This is the correct trigger for any workflow that builds, tests, or otherwise executes contributor code, because untrusted code cannot reach anything that matters.
pull_request_target — runs in the base repo's context with a read-write token and full secret access, using the base branch's workflow. Safe only for jobs that never check out or run the PR's code (labeling, commenting). Checking out the PR head under it is a public-repo remote code execution and credential leak.
- Using
pull_request_targetand then checking outgithub.event.pull_request.head.sha— this runs attacker-supplied code with a write token and your secrets, the documented exfiltration pattern that has leaked credentials from real projects. - Shipping with no issue template, so every bug arrives without a version or repro and triage time doubles while you chase missing details.
- Merging a PR with failing or skipped CI because "it is a small change" — the broken commit lands on
mainand breaks the build for every other contributor who pulls. - Having no
SECURITY.md, so a vulnerability reporter opens a public issue and discloses the exploit before a patch exists. - Letting the tracker grow to thousands of open items with no stale policy, until new contributors cannot tell which issues are real.
- Use
pull_request, neverpull_request_target, for any workflow that builds or runs contributor code. - Require a passing CI status check through branch protection before any merge to
main. - Add issue forms in
.github/ISSUE_TEMPLATE/*.ymlthat make version and reproduction required fields. - Publish a
SECURITY.mdpointing to a private reporting channel such as GitHub Security Advisories. - Keep five to ten
good first issuetickets open as an on-ramp for new contributors. - Run a stale-issue bot and document an explicit out-of-scope policy so the tracker stays legible.
Knowledge Check
A fork opens a PR that adds a build step. Which trigger lets it run without exposing your secrets?
pull_request, which gives a fork a read-only token and no secret accesspull_request_target, because the base repo's trusted context sandboxes the fork's codepush, since the fork's commits land on a branch in your repo- Any trigger is fine; fork PRs are denied secrets no matter which one fires
Why is checking out github.event.pull_request.head.sha under pull_request_target dangerous?
- It executes attacker-supplied code with the base repo's write token and secrets in scope
- The step silently fails because a fork's head commit cannot be checked out from the base repository's trusted context at all
- It only adds runtime overhead to the build with no security impact, since the checked-out fork code runs in an isolated sandbox anyway
- It permanently blocks that pull request from ever being merged
What is the practical cost of shipping without an issue template?
- Reports arrive without version or repro, so triage time doubles chasing missing details
- GitHub rejects any new issue that does not match a defined template, returning the reporter to the form until every required field is filled
- Contributors are blocked from opening pull requests until a template exists
- CI workflows refuse to run on a repo with no issue template defined under the
.githubdirectory
Why does a project need a SECURITY.md with a private reporting path?
- Without one, reporters default to a public issue and disclose the exploit before a patch exists
- GitHub disables the public repo until a security policy file is added, blocking clones and pulls until
SECURITY.mdlands on the default branch - It is a prerequisite for any SemVer-tagged release to publish
- It automatically patches and ships fixes for reported vulnerabilities the moment a private report arrives through the channel
You got correct