From 9919aab8b5ffce9abc24805a9c605818bb322379 Mon Sep 17 00:00:00 2001 From: Nicholas Hart Date: Sun, 13 Sep 2026 21:42:20 -0700 Subject: [PATCH] Write a real README for the shared workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous one was GitHub's two-line default, for a repo three sites depend on and which also shows on the org profile. Workflows first, since that is what the repo contains and why anyone arrives here: a table of the three, then a copy-pasteable caller for each with its real inputs and secrets. Both examples were diffed against the actual callers rather than written from memory. A section for the two mistakes that each cost an afternoon, because neither is discoverable from the error: - `permissions` must sit on the calling job, not the top level of the caller's file, or the token falls back to the repo default and the pull request lookup 403s. Passing GITHUB_TOKEN down as a secret does not help and is rejected outright. - a called workflow cannot discover its own version, so `shared-ref` has to be passed; omit it and a caller pinned to @v1 runs whatever script is newest. Also the two "do not" rules that are easy to get backwards: no `paths:` filter on mdx-check, because a required check that never runs blocks a pull request with nothing to click; and prose-check must not be required, because it is built to pass even when it finds something. Says plainly that `v1` is mutable — a release boundary, not an immutability guarantee — and what to do instead if that matters. The project index covers md2do and post-inbox only. The other repos are not ready to feature. Co-Authored-By: Claude Opus 5 --- README.md | 181 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 179 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9797c8c..555bfea 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,179 @@ -# .github -Public/shared workflows for TeamNickHart +# TeamNickHart + +Org-level defaults and reusable GitHub Actions workflows, shared across this +organization's repositories. + +This repo holds no application code. It exists so that a change to a check is +made once rather than three times — and because a workflow that fails to load +reports only `startup_failure`, with no logs, which is much less painful to +debug in one place than in every repo that copied it. + +## Reusable workflows + +| Workflow | Does | Blocking? | +|---|---|---| +| [`mdx-check.yml`](.github/workflows/mdx-check.yml) | Compiles every post with the site's own contentlayer config, so a post that would break the build cannot merge | **Yes** — meant to be a required check | +| [`prose-check.yml`](.github/workflows/prose-check.yml) | Spelling (`cspell`) and markdown style (`markdownlint`), reported to the job summary | No — advisory by design | +| [`notify-author.yml`](.github/workflows/notify-author.yml) | Emails a post's author a link to their Vercel preview and its pull request | No — a missing notification is a courtesy not delivered, not a reason to fail a build | + +### mdx-check + +```yaml +name: MDX check +on: + pull_request: + +jobs: + mdx: + permissions: + contents: read + uses: TeamNickHart/.github/.github/workflows/mdx-check.yml@v1 +``` + +| Input | Default | | +|---|---|---| +| `node-version` | `'24'` | Match what the site builds with, or CI and production can disagree | + +Runs the site's own `contentlayer2 build` rather than a plugin list maintained +here, because a copy of that list would drift from what the sites actually build +with — and a check that disagrees with the real build is worse than no check, in +both directions. + +**Do not add a `paths:` filter.** A required check that never runs never +reports, leaving the pull request blocked with nothing to click and no way to +merge. This is not hypothetical: a README-only pull request hit exactly that, +with every visible check green and no way forward. + +### prose-check + +```yaml +name: Prose check +on: + pull_request: + +jobs: + prose: + permissions: + contents: read + uses: TeamNickHart/.github/.github/workflows/prose-check.yml@v1 +``` + +| Input | Default | | +|---|---|---| +| `node-version` | `'24'` | | +| `content-glob` | `'data/**/*.mdx'` | Files to check, relative to the repo root | + +Tune it with two committed files in the calling repo: `cspell.json` for words +that are correct but not in a dictionary, and `.markdownlint-cli2.jsonc` for +rules that misread MDX. + +**Do not make this a required check.** It is built to succeed even when it finds +something, so requiring it would be meaningless — and making it fail would block +a merge on a spelling opinion. Findings go to the job summary, which needs no +permissions at all, where a pull request comment would need +`pull-requests: write` — a real escalation on a token that could then modify +pull requests. + +### notify-author + +```yaml +name: Notify author +on: + deployment_status: + +jobs: + notify: + permissions: + contents: read + pull-requests: read + uses: TeamNickHart/.github/.github/workflows/notify-author.yml@v1 + with: + mail-from: ${{ vars.NOTIFY_FROM }} + shared-ref: v1 + secrets: + RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }} + AUTHOR_EMAIL_MAP: ${{ secrets.AUTHOR_EMAIL_MAP }} +``` + +| Input | Default | | +|---|---|---| +| `mail-from` | **required** | Sender address. A called workflow cannot read the caller's `vars`, so this is passed in | +| `shared-ref` | `main` | See [Versioning](#versioning) | +| `content-path` | `data/blog` | | +| `route-prefix` | `/blog` | | +| `branch-prefix` | `post-inbox/` | Only branches with this prefix notify | + +| Secret | | +|---|---| +| `RESEND_API_KEY` | Resend API key. One key can serve several sites | +| `AUTHOR_EMAIL_MAP` | JSON mapping an author name to an address. Never committed — the post's frontmatter carries only the name | + +No address is ever committed or logged: it is looked up from the secret at send +time and masked in output, because an Actions log outlives the run and is +readable by anyone with repo access. + +## Two things that will cost you an afternoon + +**`permissions` must sit on the *calling job*, not at the top level of the +caller's file.** A top-level block does not reach a `workflow_call` job — the +token silently falls back to the repo default, and `notify-author`'s pull +request lookup returns 403, so the email links the pull request *list* instead +of the pull request. The `GITHUB_TOKEN Permissions` group at the top of a run's +log shows what the job actually got; that is the first thing to read when a +lookup 403s. + +Passing the caller's `GITHUB_TOKEN` down as a secret does **not** work around a +missing grant. It is the same token with the same permissions — and GitHub +rejects `secrets.GITHUB_TOKEN` passed by name into a reusable workflow, which +fails the run outright. + +**A called workflow cannot discover its own version.** `github.job_workflow_sha` +does not exist; `github.workflow_sha` and `github.workflow_ref` both describe +the *caller*. So `notify-author` takes `shared-ref`, and the caller passes the +same ref it pinned the workflow to. Omit it and the script comes from `main`, +which means a caller pinned to `@v1` runs whatever script is newest. + +## Versioning + +Callers pin `@v1`. The tag moves when a change is ready for every site, so a fix +reaches all of them by moving one tag rather than one commit per repo. + +`v1` is a **mutable** tag: a release boundary, not an immutability guarantee. +Anyone who can push here can retarget it. If you need the stronger property, pin +a caller to a commit SHA instead — `v1` then still serves as a label for which +commit that is. + +## Contributing + +CI runs on every pull request: + +- **`actionlint`**, which validates expressions against the real context types + and runs `shellcheck` over every `run:` block +- **`node --check`** on the shared script, which is fetched and executed at + runtime — so a syntax error there would surface as a failed notification +- a check that every `workflow_call` input and secret in use is declared, since + an undeclared one parses fine and arrives empty + +Locally: + +```bash +brew install actionlint +actionlint +node --check .github/scripts/notify-author.mjs +``` + +**What CI cannot catch:** whether a context is actually *populated* inside a +called workflow. `vars.X` is unavailable there and `github.job_workflow_sha` +does not exist at all — both lint clean and arrive empty at runtime. Print a +value before depending on it. + +## Projects + +| | | +|---|---| +| [**md2do**](https://github.com/TeamNickHart/md2do) | Track and manage TODOs in markdown, with MCP, VS Code, Obsidian and Todoist integrations. [md2do.com](https://md2do.com) · [`@md2do/cli`](https://www.npmjs.com/package/@md2do/cli) | +| [**post-inbox**](https://github.com/TeamNickHart/post-inbox) | Email a post to a Git-based blog. A Cloudflare Worker turns an email into a draft pull request with a live preview, and never publishes directly | + +## License + +MIT. See [LICENSE](LICENSE).