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
65 changes: 64 additions & 1 deletion docs/05-runners.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,5 +99,68 @@ you own patching, isolation, and security.
- Apply egress controls at the network layer; harden-runner's hosted-runner
features do not all apply to self-hosted.

<a id="visibility-routing"></a>
## Routing by visibility

Minutes are free and unlimited on public repositories and metered on private
ones, so the cost-optimal routing is the opposite of what "use our own hardware
everywhere" would suggest:

| Repository visibility | Route to | Why |
| --- | --- | --- |
| **public** | GitHub-hosted (`ubuntu-latest`) | free and unlimited; self-hosted here is a **security defect**, not a saving |
| **private / internal** | self-hosted label | private minutes are metered; self-hosted removes them from the bill entirely |

> **This repository is public.** Nothing in `ci-workflows` may route its own jobs
> to a self-hosted runner, and no example in `examples/` may ship a self-hosted
> label as a default. A forked pull request against a public repository executes
> attacker-controlled code, so a self-hosted runner reachable from a public repo
> is a remote-code-execution path into your own infrastructure. Every `runner`
> input in this library therefore defaults to a GitHub-hosted label, and the
> self-hosted value is supplied **by the calling private repository**, never
> baked in here.

Defence in depth: the estate's runner group sets
`allows_public_repositories: false`, so even a mistaken `runs-on` in a public
repository cannot reach the fleet — the job stays queued instead of executing.
Treat that as the backstop, not the control.

### Two independent runner settings

Switching a private repository over is **not** one change. The `runner` input
only covers workflows this library defines; GitHub's own managed scanners have a
separate setting that no workflow file can reach:

| What runs | Where the runner is chosen | How to set it |
| --- | --- | --- |
| Reusable workflows from this library | `runner` input on the caller | `with: { runner: <label> }` |
| CodeQL **default setup** (code scanning) | repository code-scanning settings | `PATCH /repos/{owner}/{repo}/code-scanning/default-setup` with `runner_type: labeled`, `runner_label: <label>` |
| **Code Quality** scans | repository Code quality settings | UI only — *Runner type → Labeled runner*; there is no REST API |

Miss either of the last two and the repository still burns metered minutes even
though every caller says otherwise — the scans are scheduled by GitHub, not by a
workflow file in the repository.

### Caller shape

```yaml
# private repository — self-hosted label supplied by the caller
jobs:
validate:
permissions: { contents: read }
uses: NDDev-it-com/ci-workflows/.github/workflows/private-static.yml@<full-sha>
with:
runner: <your-self-hosted-label>
command: "python3 scripts/validate_all.py"
```

Public callers simply omit `runner` and take the GitHub-hosted default.

> **Capacity, not fallback.** GitHub Actions has **no** automatic spillover from
> a self-hosted label to a hosted runner: a job whose label is busy queues until
> a runner frees up. Size the fleet so queueing is rare rather than trying to
> engineer a fallback — and note that a fallback to hosted runners on a *private*
> repo would silently reintroduce the metered minutes you moved off.

---
Last verified: 2026-07-10
Last verified: 2026-08-01
38 changes: 38 additions & 0 deletions docs/16-code-quality.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,44 @@ is why its catalog entry carries `workflow: null` and `example: null`.
Prefer `Selected repositories` + `Enforce access`: `All repositories` silently
pulls every public repo into a paid product.

> The NDDev estate runs `All repositories` + `Enforce access` on purpose. That is
> not a contradiction of the advice above — it follows from the same arithmetic:
> the licence bills once per active committer, so at one committer the fiftieth
> repository costs exactly what the first one did. Apply the cautious default
> whenever the committer count is greater than one, or when "every public repo"
> would mean repos you do not control. See
> [17 NDDev tier](17-nddev-tier.md#cost-envelope).

<a id="ai-findings"></a>
## AI findings are a second, separately metered product

The repository page carries **two** switches, and only the first is covered by
the $10 licence:

| Switch | Billing |
| --- | --- |
| **Code Quality analysis** — CodeQL quality queries | included in the per-committer licence; unmetered |
| **AI findings** — AI-generated findings on push | **metered separately: AI credits, with no included allowance** |

Every AI-credit line in the billing API shows `discountAmount: 0.00` — nothing is
bundled. Observed rate: **$0.01 per credit**, and a single mid-sized repository
burned **774.9 credits in roughly twelve days** — about **$19/month for one
repository**, i.e. nearly twice the licence that covers the whole organization.

Two consequences worth stating plainly:

- **A product budget cannot fence this off.** A Code Quality budget must leave
at least $10 of headroom for the licence, and AI credits accrue into that same
headroom before any hard stop trips. The per-repository switch is the only
real control.
- **The switch is absent where CodeQL finds no supported language.** Those
repositories render *"No CodeQL supported languages to scan in this
repository"* and cannot generate AI credits at all — a stronger guarantee than
the switch being off, and not something to "fix".

Leave **AI findings off** unless the credit spend has been sized against the
licence for that specific repository.

## Using it as a merge gate

Enablement only produces findings. To make it block a bad merge, add the ruleset
Expand Down
63 changes: 58 additions & 5 deletions docs/17-nddev-tier.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ Security suite: [`examples/nddev/security.yml`](../examples/nddev/security.yml).
It is the private-paid/GHAS suite plus `osv-scan.yml`, and it runs unchanged on
public repositories.

Private repositories take the same suite with every job pinned to the
self-hosted fleet:
[`examples/nddev/security-private-selfhosted.yml`](../examples/nddev/security-private-selfhosted.yml).
The coverage is identical — only the `runner` inputs differ — so a repository
changing visibility switches example, not posture.

Release with provenance — on **private** repos too:

```yaml
Expand All @@ -92,11 +98,12 @@ The manifest records `slsa_build_level: 3` rather than the `null` that
not include it.
- **SAML SSO, IP allow list, SSH certificate authorities.** None configured at
org or enterprise level.
- **Enforced SHA pinning.** Both org and enterprise report
`sha_pinning_required: false`. This library enforces full-SHA pins on itself
through `scripts/check_pinned_actions.py`, but the platform does not enforce it
on other repositories in the estate. Turning it on org-wide would break any
repository still referencing actions by tag — audit before enabling.

**Enforced SHA pinning is now on.** Both org and enterprise report
`sha_pinning_required: true`, so the platform rejects tag-referenced actions
estate-wide, not just in this library. `scripts/check_pinned_actions.py` remains
the pre-merge gate; the platform setting is the backstop for repositories that
do not run it.

## Cost note that governs tier choice

Expand All @@ -107,5 +114,51 @@ all fifty are enabled. That is why `nddev-config` is attached to all 50 rather
than a chosen subset: partial coverage would have cost exactly the same and
protected less. Re-evaluate that reasoning the moment a second committer joins.

<a id="cost-envelope"></a>
## The $80/month envelope

Unit prices read from the billing API on 2026-08-01, at one active committer:

| Line | Rate | Monthly |
| --- | --- | --- |
| Enterprise Cloud | $21.00 / user | $21.00 |
| Code Security | $30.00 / active committer | $30.00 |
| Secret Protection | $19.00 / active committer | $19.00 |
| Code Quality | $10.00 / active committer | $10.00 |
| **Fixed total** | | **$80.00** |

Everything else is metered and deliberately driven to zero:

| Metered line | Control |
| --- | --- |
| Actions minutes | $0 hard-stop budget at **org and enterprise**; private jobs routed to self-hosted |
| Actions **storage** | budgets do **not** block storage — controlled by 1-day artifact/log retention with the org maximum also pinned to 1 |
| Code Quality **AI credits** | AI findings off on every repository; the $10 product budget is a backstop, not the control |
| Codespaces, Packages, Git LFS, Models, Sandbox, Spark | $0 hard-stop budgets at both levels |

Two facts worth carrying:

- **A product budget cannot protect a metered line that accrues under the same
SKU as a licence.** The Code Quality budget has to leave $10 of headroom for
the licence itself, and AI credits accrue into that same headroom. The only
real control for AI credits is the per-repository toggle.
- **Seat count is the one thing no budget bounds.** A second active committer
adds $59/month (Code Security + Secret Protection) before anyone writes a
workflow. Org membership, outside-collaborator invitation, and enterprise
member purchasing are all closed for that reason.

<a id="runner-routing"></a>
## Runner routing in this estate

Private repositories run on the estate's self-hosted fleet; public repositories
stay on GitHub-hosted runners, where minutes are free and self-hosted would be a
security defect. Mechanics, the two settings that a workflow file cannot reach,
and the reason this public repository never routes itself to self-hosted:
[05 Runners → Routing by visibility](05-runners.md#visibility-routing).

Current state: all 26 private repositories have CodeQL default setup on
`runner_type: labeled` and Code Quality on *Labeled runner*; all 24 public
repositories remain GitHub-hosted.

---
Last verified: 2026-08-01
61 changes: 61 additions & 0 deletions examples/nddev/security-private-selfhosted.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# NDDev estate tier — PRIVATE repository variant.
#
# Identical coverage to examples/nddev/security.yml; the only difference is that
# every job is routed to the estate's self-hosted fleet so that the metered
# private-repository minutes never reach the bill.
#
# 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. The
# estate's runner group also sets allows_public_repositories: false, but treat
# that as a backstop rather than the control.
#
# Two scans are NOT configured by this file and must be set per repository, or
# they keep consuming metered minutes regardless of what is written here:
# * CodeQL default setup -> PATCH /repos/{owner}/{repo}/code-scanning/default-setup
# with runner_type=labeled, runner_label=<label>
# * Code Quality -> repository Settings -> Code quality -> Labeled runner
# See docs/05-runners.md#visibility-routing.
#
# Replace @<sha> with a pinned full commit SHA of ci-workflows, and <label> with
# the self-hosted runner label used by your estate.
name: security
on:
push: { branches: [main] }
pull_request: { branches: [main] }
permissions: {}
concurrency:
group: security-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
codeql:
permissions: { actions: read, contents: read, security-events: write }
uses: NDDev-it-com/ci-workflows/.github/workflows/public-codeql.yml@<sha>
with:
languages: '["python","actions"]'
runner: <label>
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, security-events: write }
uses: NDDev-it-com/ci-workflows/.github/workflows/zizmor-sarif.yml@<sha>
with:
runner: <label>
dependency-review:
permissions: { contents: read, pull-requests: write }
uses: NDDev-it-com/ci-workflows/.github/workflows/public-dependency-review.yml@<sha>
with:
runner: <label>
osv-scan:
permissions: { contents: read }
uses: NDDev-it-com/ci-workflows/.github/workflows/osv-scan.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 @@ -140,6 +140,7 @@ def check() -> list[str]:
"examples/private-free/security.yml",
"examples/private-paid-ghas/security.yml",
"examples/nddev/security.yml",
"examples/nddev/security-private-selfhosted.yml",
}
missing_examples -= allowed_aggregate_examples
if missing_examples:
Expand Down
Loading