Skip to content

Commit de2e0db

Browse files
committed
Add reusable workflows alongside the existing starter templates
Why this exists --------------- The workflow templates under workflow-templates/ solve "scaffold me a CI workflow." They work via copy-on-init: GitHub copies the file into the repo's .github/workflows/ and from then on the repo owns that copy. This is great for repos that want to pin the behaviour and pick up changes deliberately. It's bad for the org as a whole: when we fix a bug in the Rust template, the 5 repos that already adopted it do not get the fix unless each maintainer re-copies it. Reusable workflows solve the same problem from the other side: the logic lives in ONE place (this repo's .github/workflows/), and consuming repos reference it with `uses:`. Fixes propagate to every adopter automatically. The cost is that every caller is coupled to this repo's main branch (or a pinned SHA). Rather than pick one model and force every repo into it, we ship BOTH and document the tradeoff. Each repo chooses per workflow: - Want to pin the behaviour? Use the starter template. It's a copy. Upgrades are explicit. - Want to stay in lockstep with the org? Call the reusable workflow. Upgrades are automatic. Breakage is also automatic when we change something. What's in this commit --------------------- Eight reusable workflows under .github/workflows/, one per starter template. Each preserves the exact behaviour of the matching template (same tools, same commands, same options) but re-shaped for workflow_call with explicit `inputs:` and `secrets:` blocks. CI workflows: reusable-ci-nextjs-monorepo.yml Inputs: tasks (space-separated, default "lint typecheck test build"), node-version-file (default .nvmrc). Secrets: TURBO_TOKEN, TURBO_TEAM (both optional). Behaviour: matrix job per task, turbo filter auto-computed from PR base SHA or HEAD^1. reusable-ci-rust-monorepo.yml Input: toolchain (default stable). Behaviour: unchanged from the starter - paths-filter gate, fmt + clippy + nextest + build + doc jobs with rust-cache. reusable-ci-python-monorepo.yml No inputs - convention-based. Assumes uv workspace at the repo root with .python-version, uv.lock, packages/* layout. Behaviour: unchanged from the starter - two-layer change detection, ruff + mypy + per-package pytest matrix. reusable-ci-docs-mdx.yml Inputs: build-command (default "pnpm build"), node-version-file (default .nvmrc), files-glob (default "**/*.{md,mdx}"). Behaviour: cspell incremental on PRs, lychee link check with cache, site build. Security and policy workflows: reusable-codeql.yml REQUIRED input: languages - a JSON array of {language, build-mode} objects. Expressed this way so callers only declare the languages their repo actually uses, without us having to ship separate one-language reusables. reusable-dependency-review.yml Inputs: fail-on-severity (default moderate), comment-summary-in-pr (default on-failure). reusable-pr-title-lint.yml Input: require-scope (default false). Types list baked in to match CONTRIBUTING.md - when that list changes, change it here and in the starter template too. reusable-stale.yml Fully parameterised: days-before-*, exempt-*-labels, operations-per-run. Defaults match what stale.yml currently uses. Caller provides the schedule (cron) since workflow_call can't carry schedule triggers through to the reusable. Notable decisions ----------------- - Every reusable declares its own permissions: block at the workflow or job level. Callers must grant AT LEAST those permissions on the calling job; if they grant less, GitHub silently downgrades and steps fail in hard-to-debug ways. Each reusable's header comment spells out what the caller needs. - No SHA-pinning of internal `uses:` inside the reusables. Tag pins (@v4, @v5) are acceptable here because Dependabot now tracks this repo's own Actions dependencies (see commit 44dfac9). Consuming repos that want SHA-pinning should reference this repo's reusables by SHA, not tag. - Reusables do NOT declare `on:` triggers other than workflow_call. The caller owns the trigger surface. This is non-negotiable: reusables that try to declare pull_request will simply never run. - I kept Rust's dtolnay/rust-toolchain switched from `@stable` to `@master` with a `toolchain:` input, because the former is an alias that only points at stable and can't be parameterised. Functionally identical when the input is left at its default. What this deliberately does NOT do ---------------------------------- - Does not delete, rewrite, or otherwise touch workflow-templates/. The starter templates remain valid standalone workflows. Repos that already adopted them keep working. - Does not ship .properties.json for the reusables. Reusable workflows don't show up in the "New workflow" picker - they're called, not scaffolded - so properties metadata is irrelevant. - Does not enforce which path a repo picks. Platform team can recommend; repos choose per workflow. - Does not set up a release versioning scheme (e.g. v1/v2 major- version tags pointing at the tip of each major). Callers use @main today. A future commit can add moving major tags once we've actually broken something. Cross-references ---------------- - README.md: the Reusable workflow templates section is now split into Path A (starter templates) and Path B (reusable workflows) with a short paragraph explaining when to pick each. Both tables are populated with every shipped file. - ORG_SETTINGS.md § Actions permissions: the org Actions allow-list must include `nyuchitech/*` (workflows from the same org are allowed by default, but if the allow-list is tightened, the reusables' paths must remain reachable). - AGENTS.md § Security: "Pin third-party GitHub Actions by SHA" applies to the reusables too - consuming repos that care about this should reference the reusables by SHA, not @main.
1 parent bcbf40e commit de2e0db

9 files changed

Lines changed: 697 additions & 1 deletion
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# Reusable workflow: Docs / MDX site CI.
2+
#
3+
# Call from a caller workflow in a consuming repo:
4+
#
5+
# jobs:
6+
# ci:
7+
# uses: nyuchitech/.github/.github/workflows/reusable-ci-docs-mdx.yml@main
8+
# with:
9+
# build-command: "pnpm build"
10+
#
11+
# The caller must grant `contents: read`.
12+
13+
name: Reusable / CI / Docs MDX
14+
15+
on:
16+
workflow_call:
17+
inputs:
18+
build-command:
19+
description: Shell command that builds the site.
20+
type: string
21+
default: "pnpm build"
22+
node-version-file:
23+
description: Path to the file pinning the Node version.
24+
type: string
25+
default: .nvmrc
26+
files-glob:
27+
description: Glob used by cspell and lychee to find content files.
28+
type: string
29+
default: "**/*.{md,mdx}"
30+
31+
permissions:
32+
contents: read
33+
34+
jobs:
35+
spellcheck:
36+
name: Spellcheck (cspell)
37+
runs-on: ubuntu-latest
38+
steps:
39+
- uses: actions/checkout@v4
40+
with:
41+
fetch-depth: 0
42+
- uses: streetsidesoftware/cspell-action@v6
43+
with:
44+
incremental_files_only: ${{ github.event_name == 'pull_request' }}
45+
files: ${{ inputs.files-glob }}
46+
47+
link-check:
48+
name: Link check (lychee)
49+
runs-on: ubuntu-latest
50+
steps:
51+
- uses: actions/checkout@v4
52+
- uses: actions/cache@v4
53+
with:
54+
path: .lycheecache
55+
key: lychee-${{ github.sha }}
56+
restore-keys: lychee-
57+
- uses: lycheeverse/lychee-action@v2
58+
with:
59+
args: >-
60+
--cache
61+
--max-cache-age 1d
62+
--no-progress
63+
--max-concurrency 8
64+
--exclude-mail
65+
'./**/*.md'
66+
'./**/*.mdx'
67+
fail: true
68+
69+
build:
70+
name: Build site
71+
runs-on: ubuntu-latest
72+
steps:
73+
- uses: actions/checkout@v4
74+
- uses: pnpm/action-setup@v4
75+
- uses: actions/setup-node@v4
76+
with:
77+
node-version-file: ${{ inputs.node-version-file }}
78+
cache: pnpm
79+
- run: pnpm install --frozen-lockfile
80+
- name: Build
81+
run: ${{ inputs.build-command }}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# Reusable workflow: Next.js monorepo CI (Turborepo + pnpm).
2+
#
3+
# Call from a caller workflow in a consuming repo:
4+
#
5+
# jobs:
6+
# ci:
7+
# uses: nyuchitech/.github/.github/workflows/reusable-ci-nextjs-monorepo.yml@main
8+
# with:
9+
# tasks: "lint typecheck test build"
10+
# secrets:
11+
# TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
12+
# TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
13+
#
14+
# The caller must grant `contents: read`.
15+
16+
name: Reusable / CI / Next.js monorepo
17+
18+
on:
19+
workflow_call:
20+
inputs:
21+
tasks:
22+
description: Space-separated list of turbo tasks to run (matrixed one job per task).
23+
type: string
24+
default: "lint typecheck test build"
25+
node-version-file:
26+
description: Path to the file pinning the Node version.
27+
type: string
28+
default: .nvmrc
29+
secrets:
30+
TURBO_TOKEN:
31+
description: Turborepo Remote Cache token. Optional.
32+
required: false
33+
TURBO_TEAM:
34+
description: Turborepo Remote Cache team slug. Optional.
35+
required: false
36+
37+
permissions:
38+
contents: read
39+
40+
jobs:
41+
matrix:
42+
name: resolve matrix
43+
runs-on: ubuntu-latest
44+
outputs:
45+
tasks: ${{ steps.split.outputs.tasks }}
46+
steps:
47+
- id: split
48+
run: |
49+
json=$(printf '%s\n' ${{ inputs.tasks }} | jq -R . | jq -c -s .)
50+
echo "tasks=$json" >> "$GITHUB_OUTPUT"
51+
52+
ci:
53+
name: ${{ matrix.task }}
54+
needs: matrix
55+
runs-on: ubuntu-latest
56+
strategy:
57+
fail-fast: false
58+
matrix:
59+
task: ${{ fromJSON(needs.matrix.outputs.tasks) }}
60+
env:
61+
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
62+
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
63+
TURBO_FILTER: >-
64+
${{ github.event_name == 'pull_request'
65+
&& format('...[{0}]', github.event.pull_request.base.sha)
66+
|| '...[HEAD^1]' }}
67+
steps:
68+
- uses: actions/checkout@v4
69+
with:
70+
fetch-depth: 0
71+
- uses: pnpm/action-setup@v4
72+
- uses: actions/setup-node@v4
73+
with:
74+
node-version-file: ${{ inputs.node-version-file }}
75+
cache: pnpm
76+
- run: pnpm install --frozen-lockfile
77+
- name: turbo run ${{ matrix.task }}
78+
run: pnpm turbo run ${{ matrix.task }} --filter="${TURBO_FILTER}"
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
# Reusable workflow: Python monorepo CI (uv workspace).
2+
#
3+
# Call from a caller workflow in a consuming repo:
4+
#
5+
# jobs:
6+
# ci:
7+
# uses: nyuchitech/.github/.github/workflows/reusable-ci-python-monorepo.yml@main
8+
#
9+
# The caller must grant `contents: read`.
10+
#
11+
# Assumes:
12+
# - Root pyproject.toml with [tool.uv.workspace] members = ["packages/*"]
13+
# - Root uv.lock (committed)
14+
# - Per-package source under packages/<name>/
15+
# - .python-version at the repo root
16+
# - ruff, mypy, pytest declared as dev dependencies in the root pyproject.toml
17+
18+
name: Reusable / CI / Python monorepo
19+
20+
on:
21+
workflow_call:
22+
23+
permissions:
24+
contents: read
25+
26+
jobs:
27+
changes:
28+
name: detect changes
29+
runs-on: ubuntu-latest
30+
outputs:
31+
any_python: ${{ steps.filter.outputs.any_python }}
32+
root_changed: ${{ steps.filter.outputs.root_changed }}
33+
packages: ${{ steps.packages.outputs.packages }}
34+
steps:
35+
- uses: actions/checkout@v4
36+
with:
37+
fetch-depth: 0
38+
39+
- id: filter
40+
uses: dorny/paths-filter@v3
41+
with:
42+
filters: |
43+
any_python:
44+
- '**/*.py'
45+
- '**/pyproject.toml'
46+
- 'uv.lock'
47+
- '.python-version'
48+
- '.github/workflows/**'
49+
root_changed:
50+
- 'pyproject.toml'
51+
- 'uv.lock'
52+
- '.python-version'
53+
54+
- name: Compute affected packages
55+
id: packages
56+
env:
57+
ROOT_CHANGED: ${{ steps.filter.outputs.root_changed }}
58+
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before || '' }}
59+
HEAD_SHA: ${{ github.sha }}
60+
run: |
61+
set -euo pipefail
62+
63+
NULL_SHA="0000000000000000000000000000000000000000"
64+
all_packages() {
65+
if [ -d packages ]; then
66+
find packages -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort
67+
fi
68+
}
69+
70+
if [ "$ROOT_CHANGED" = "true" ] \
71+
|| [ -z "$BASE_SHA" ] \
72+
|| [ "$BASE_SHA" = "$NULL_SHA" ]; then
73+
mapfile -t pkgs < <(all_packages)
74+
else
75+
mapfile -t pkgs < <(
76+
git diff --name-only "$BASE_SHA" "$HEAD_SHA" \
77+
| awk -F/ '$1=="packages" && NF>2 {print $2}' \
78+
| sort -u
79+
)
80+
fi
81+
82+
json=$(printf '%s\n' "${pkgs[@]}" | jq -R . | jq -c -s 'map(select(length>0))')
83+
echo "packages=$json" >> "$GITHUB_OUTPUT"
84+
echo "Affected packages: $json"
85+
86+
lint:
87+
name: ruff check
88+
needs: changes
89+
if: needs.changes.outputs.any_python == 'true'
90+
runs-on: ubuntu-latest
91+
steps:
92+
- uses: actions/checkout@v4
93+
- uses: astral-sh/setup-uv@v5
94+
with:
95+
enable-cache: true
96+
- run: uv sync --all-packages --frozen
97+
- run: uv run ruff check .
98+
99+
format:
100+
name: ruff format --check
101+
needs: changes
102+
if: needs.changes.outputs.any_python == 'true'
103+
runs-on: ubuntu-latest
104+
steps:
105+
- uses: actions/checkout@v4
106+
- uses: astral-sh/setup-uv@v5
107+
with:
108+
enable-cache: true
109+
- run: uv sync --all-packages --frozen
110+
- run: uv run ruff format --check .
111+
112+
typecheck:
113+
name: mypy
114+
needs: changes
115+
if: needs.changes.outputs.any_python == 'true'
116+
runs-on: ubuntu-latest
117+
steps:
118+
- uses: actions/checkout@v4
119+
- uses: astral-sh/setup-uv@v5
120+
with:
121+
enable-cache: true
122+
- run: uv sync --all-packages --frozen
123+
- run: uv run mypy packages
124+
125+
test:
126+
name: pytest (${{ matrix.package }})
127+
needs: changes
128+
if: needs.changes.outputs.packages != '[]'
129+
runs-on: ubuntu-latest
130+
strategy:
131+
fail-fast: false
132+
matrix:
133+
package: ${{ fromJSON(needs.changes.outputs.packages) }}
134+
steps:
135+
- uses: actions/checkout@v4
136+
- uses: astral-sh/setup-uv@v5
137+
with:
138+
enable-cache: true
139+
- run: uv sync --all-packages --frozen
140+
- name: pytest ${{ matrix.package }}
141+
run: uv run --package ${{ matrix.package }} pytest packages/${{ matrix.package }}

0 commit comments

Comments
 (0)