Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,24 @@

## [Unreleased]

### Added

- **Personal-account consumer tier.** A repository owned by a *personal* GitHub
account (not an organization) inherits the private-free posture but cannot
reach an org-level self-hosted runner group — it needs a **repo-level**
runner registration and a caller that routes every job to that label. New
[docs/18-personal-account-tier.md](docs/18-personal-account-tier.md) records
the runner-registration procedure (org-runner vs repo-runner reachability is a
platform gate, not a setting), the inversion against [02 private-free], and
the **trigger caveat**: cross-owner reusable workflows do not resolve jobs for
`push` events to the default branch of a personal-account repo (verified
empirically — `pull_request` works, `push` hangs in `pending` with zero jobs).
New [examples/personal/security-selfhosted.yml](examples/personal/security-selfhosted.yml)
is the private-free stack (gitleaks, actionlint, zizmor-no-SARIF) with a
`runner:` input on every call and `pull_request`-only triggers. No catalog or
workflow file changed — the capability set is identical to private-free; only
the runner and trigger differ.

## [0.13.3] - 2026-08-03

### Added
Expand Down
13 changes: 12 additions & 1 deletion docs/00-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,16 @@ most consequential correction: Artifact Attestations are gated on Enterprise
Cloud for private repos, and this estate **has** it, so private repositories
release with full provenance instead of the `-free` variant.

### Personal-account repositories

A repository owned by a **personal** account (not an organization) takes the
[private-free](02-private-free.md) posture regardless of visibility — it has no
GHAS, no Enterprise Cloud, and a 2 000 min/month Actions quota with no org pool.
Its runner strategy differs from a generic private-free repo: every job is
routed to a **repo-level** self-hosted runner, because an org-level runner is
not reachable from a personal-account namespace. See
[18 Personal-account tier](18-personal-account-tier.md).

## How to consume a reusable workflow

Reference by `owner/repo/.github/workflows/<name>.yml@<full-sha>` from a caller
Expand Down Expand Up @@ -106,7 +116,8 @@ For end-to-end caller examples per tier, see the tier docs and the repository
[02 Private free](02-private-free.md) ·
[03 Private paid / GHAS](03-private-paid-ghas.md) ·
[16 Code Quality](16-code-quality.md) ·
[17 NDDev estate](17-nddev-tier.md)
[17 NDDev estate](17-nddev-tier.md) ·
[18 Personal account](18-personal-account-tier.md)
- Platform: [04 Actions core](04-actions-core.md) · [05 Runners](05-runners.md)
- Security: [06 Security scanning](06-security-scanning.md) ·
[07 Supply chain / SLSA / SBOM / attestations](07-supply-chain-slsa-sbom-attestations.md)
Expand Down
114 changes: 114 additions & 0 deletions docs/18-personal-account-tier.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# Personal-account tier — a private repo under a personal GitHub account

The other tier docs classify a repository by **visibility** (public / private)
and **plan** (free / GHAS). This one adds a third axis the generic model does
not surface: whether the repository is owned by an **organization** or by a
**personal account**. A personal-account repository is the *degenerate* case of
private-free — it inherits that posture in full, *and* it cannot reach an
organization's self-hosted runner fleet, so its runner strategy is different.

## Why this doc exists

The [NDDev estate tier](17-nddev-tier.md) records that the `NDDev-it-com`
organization has bought Enterprise Cloud, Code Security, Secret Protection, and
Code Quality, so its private repositories run the paid stack. **A repository
owned by a personal account has none of that**, even if the same human owns both.
Licenses attach to the organization, not to the user's personal namespace, so:

- a private personal-account repo has **no GHAS** → no CodeQL, no native secret
scanning, no dependency review;
- a private personal-account repo has **no Enterprise Cloud** → no Artifact
Attestations;
- a private personal-account repo is billed against the **2 000 min/month**
personal Actions quota (catalog fact
`github-actions-private-free-personal`), with no organization pool to spill
into.

Functionally this is identical to [02 private-free](02-private-free.md). The
runner routing is the only part that differs, and it is the reason this doc
exists.

## The correction this tier exists to make

[02 private-free] tells a private repo to use GitHub-hosted runners and simply
accept the 2 000 min/month ceiling. For a *personal-account* repository that
ceiling is per-user, not per-organization, and there is no committer-pool to
amortize it across. A single active project with a few minutes of CI per push
can exhaust the monthly quota in days. **Route every job to a self-hosted
runner registered on the repository itself.**

| Concern | Generic private-free | Personal-account repo |
| --- | --- | --- |
| Posture (capabilities) | private-free | **private-free** (same) |
| CodeQL / secret scanning / dep review | excluded (paid) | **excluded** (no GHAS, same exclusions) |
| Artifact attestations | `release-supply-chain-free.yml` | **`release-supply-chain-free.yml`** (same) |
| Runner | GitHub-hosted `ubuntu-latest` | **self-hosted, repo-level registration** |

## Runner registration: org vs personal

A self-hosted runner registered on an **organization** is reachable only from
repositories *inside that organization*. A repository owned by a personal
account is in a different namespace and **cannot** consume an org-level runner
group, regardless of the group's `visibility` setting — `visibility=all` means
"all repos *in this org*", not "all repos everywhere". This is a platform gate,
not a configuration oversight; it cannot be relaxed by settings.

To serve a personal-account repository, register the runner **on the repository
itself**:

1. `gh api -X POST repos/{owner}/{repo}/actions/runners/registration-token` → a
short-lived token (≈1 h). This endpoint is available on private repos with
admin access; GitHub Pro is **not** required for repo-level runner
registration.
2. On the runner host, run `./config.sh --url https://github.com/{owner}/{repo}
--token <token> --labels "<alias>,linux,x64"` once per repository. A single
runner process serves exactly one repository; N repositories need N
separate runner install directories.
3. Install the systemd unit (`./svc.sh install <user> && ./svc.sh start`) so the
listener survives reboots.
4. In the caller workflow, set `runs-on: [self-hosted, Linux, X64, <alias>]` and
pass the same label as `runner:` to every reusable workflow call.

See [05 Runners → Routing by visibility](05-runners.md#routing-by-visibility)
for the load-bearing invariant: a self-hosted runner reachable from a public
repository is an RCE path, so this routing is for **private** personal-account
repos only. If the repository is or may become public, use the GitHub-hosted
caller in [examples/private-free/security.yml](../examples/private-free/security.yml).

## Recommended caller

[`examples/personal/security-selfhosted.yml`](../examples/personal/security-selfhosted.yml)
— the private-free stack (gitleaks, actionlint, zizmor-no-SARIF) with every job
routed to the repository's self-hosted runner. It is
`examples/private-free/security.yml` plus a `runner:` input on each call; the
capability set is identical because the tier posture is identical.

## Trigger caveat: `pull_request` only, not `push`

A reusable workflow owned by a **different account** (e.g. this library,
`NDDev-it-com/ci-workflows`, called from a personal-account repository) does
**not** resolve jobs for `push` events to the default branch. The run is
created but hangs in `pending` with zero jobs — indefinitely. This was
verified empirically on `rldyourmnd/declaro-platform`: the same workflow file
succeeded instantly on `pull_request` (run completed in seconds) while `push`
runs to `main` hung for 8+ hours with no jobs spawned. The mechanism is a
GitHub-side cross-owner workflow resolution gate, not a misconfiguration —
`allowed_actions: all` and full admin access do not unblock it.

**Use `pull_request` + `workflow_dispatch` as the only triggers.** Do not add
`push: branches: [main]`. Direct pushes to the default branch are gated by
branch protection requiring this CI to pass on the PR first, which is the
intended flow anyway. The example caller reflects this.

## What is deliberately not here

- **No CodeQL, no SARIF upload, no dependency review, no native secret
scanning.** These are paid on private repos and a personal account has not
bought them. The free substitutes (gitleaks, zizmor-no-SARIF, semgrep) are
what this tier runs.
- **No Artifact Attestations on release.** They require GitHub Enterprise Cloud
on private repos; use `release-supply-chain-free.yml` (SBOM + checksums, no
provenance).
- **No cross-account runner sharing.** An org runner does not serve a
personal-account repo; do not attempt to relax this with settings, it is a
platform gate.
51 changes: 51 additions & 0 deletions examples/personal/security-selfhosted.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Personal-account tier — PRIVATE repository variant.
#
# A repository owned by a *personal* GitHub account (e.g. `rldyourmnd/*`) takes
# the **private-free** posture regardless of visibility: even when private, it
# has no GitHub Advanced Security, no native secret scanning, no CodeQL, no
# dependency review — exactly the same exclusions as [02 private-free]. The
# difference from `examples/private-free/security.yml` is only the **runner**:
# a personal account has a 2 000 min/month Actions quota with no org pool to
# draw on, so every job is routed to a self-hosted runner to keep the meter at
# zero. See [docs/18-personal-account-tier.md].
#
# Do NOT copy this file into a public repository. A forked pull request against
# a public repo executes attacker-controlled code, so pointing a public repo at
# a self-hosted runner is a remote-code-execution path into your own hardware.
# If the repository is or may become public, use `examples/private-free/security.yml`
# (GitHub-hosted) or the public-OSS suite instead.
#
# Replace @<sha> with a pinned full commit SHA of ci-workflows, and <label>
# with the self-hosted runner label registered on the repository (e.g.
# `server-nddev-amsterdam`). Repo-level runner registration is required because
# a self-hosted runner registered on an *organization* is not reachable from a
# repository owned by a personal account — see
# [docs/05-runners.md#visibility-routing].
name: security
# Trigger note: pull_request + workflow_dispatch only, NOT push. Cross-owner
# reusable workflows do not resolve jobs for push events to the default branch
# of a personal-account repo (verified — see docs/18-personal-account-tier.md
# "Trigger caveat"). Branch protection gates direct merges via PR CI.
on:
pull_request: { branches: [main] }
workflow_dispatch:
permissions: {}
concurrency:
group: security-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
secret-scan:
permissions: { contents: read }
uses: NDDev-it-com/ci-workflows/.github/workflows/secret-scan.yml@<sha>
with:
runner: <label>
actionlint:
permissions: { contents: read }
uses: NDDev-it-com/ci-workflows/.github/workflows/actionlint.yml@<sha>
with:
runner: <label>
zizmor:
permissions: { contents: read } # least privilege: no security-events: write
uses: NDDev-it-com/ci-workflows/.github/workflows/zizmor-no-sarif.yml@<sha>
with:
runner: <label>
1 change: 1 addition & 0 deletions scripts/validate_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ def check() -> list[str]:
"examples/private-paid-ghas/security.yml",
"examples/nddev/security.yml",
"examples/nddev/security-private-selfhosted.yml",
"examples/personal/security-selfhosted.yml",
}
missing_examples -= allowed_aggregate_examples
if missing_examples:
Expand Down
Loading