The publisher's only workflow is .github/workflows/sync-standards.yml. It
runs scripts/vendor-standards.mjs to fan out a new version of
standards/*.md to every consumer repo.
This page is a job-by-job walkthrough — what each step does, why it exists,
and what it produces. For why this design (fail-loud, no auto-merge, etc.)
see drift-detection.md and the repo's
core-principles.md.
on:
push:
branches: [main]
paths:
- 'standards/**'The workflow fires on pushes to main that change files under
standards/. A push that only touches scripts/, .github/, or
README.md does not trigger a sync — those don't change what gets
vendored. A tag push does not trigger it; a workflow_dispatch is not wired
up; PRs to main do not trigger it (the push from the merge does).
The vendor script is also runnable by hand from inside the repo:
node scripts/vendor-standards.mjs # open PRs on every consumer
node scripts/vendor-standards.mjs --local /tmp/v # write to /tmp/v for inspectionThe --local mode skips all git operations and just writes the vendored
files. Useful for previewing what would change without spinning up CI.
The workflow has one job: sync-standards. All steps run on
ubuntu-latest (the vendoring script is pure Node — Linux, macOS, and
Windows would all work the same; ubuntu is chosen because every other
Action runs there and the runner image is already cached).
- uses: actions/checkout@v5
with:
fetch-depth: 1
token: ${{ secrets.GITHUB_TOKEN }}Shallow clone is fine — the script only needs HEAD for the SHA it bakes
into each sync header. No tags, no history.
env:
GH_TOKEN: ${{ secrets.STANDARDS_SYNC_TOKEN }}
run: node scripts/vendor-standards.mjsThis is the load-bearing step. The script:
- Resolves
gitHeadShort()fromn3ary/standards@HEAD— the SHA that goes into every vendored file's sync header. - Iterates the
CONSUMERSarray (an in-source list — see the script for the canonical list of consumer repos and their per-repovendorDir). - For each consumer:
- Clones the repo to
/tmp/vendor-<repo>-<sha>at depth 1. - Creates a new branch
chore/vendor-standards-<sha>. - Walks
standards/*.md, skippingREADME.mdandSHARED-STANDARDS.md(manifest + index stay canonical-only) and any file in the consumer'sskipset (e.g.feed-agnostic.mdonn3ary/app, which is a app-specific doc that lives indocs/standards/because the renderer looks there, but isn't actually a shared rule). - Computes the new file content: sync header + the canonical body.
- Compares against the existing file. If identical, skip — this is what stops the workflow from churning consumer PRs when the publisher only touched files that consumer doesn't import.
- If anything changed: commit on the local branch, push, open a PR via
gh pr create --repo <consumer> --base main --head chore/vendor-standards-<sha>.
- Clones the repo to
- Cleans up the temp clone. Failures in one consumer do not abort the others — each consumer is wrapped in its own try/catch so a single broken clone can't poison the rest of the run.
- name: Summary
if: always()
run: |
echo "## Standards sync" >> $GITHUB_STEP_SUMMARY
...Writes a short summary table to $GITHUB_STEP_SUMMARY so the run page
shows what happened at a glance. The script already prints
[<consumer>] opened PR: <url> to stdout for each consumer it touched;
this step just makes that visible in the Actions UI.
permissions:
contents: readThe workflow needs no extra permissions on n3ary/standards itself — the
checkout is contents: read. The cross-repo push + PR creation requires the
STANDARDS_SYNC_TOKEN secret described in architecture.md.
Per consumer, on every run:
| State | Output |
|---|---|
| Canonical changed something the consumer imports | One PR opened in the consumer (chore/vendor-standards-<sha> branch → main). Step stdout prints the PR URL. |
Canonical changed only README.md / SHARED-STANDARDS.md / skipped files |
No PR — consumer already has what it needs. |
| Canonical didn't change anything new vs the consumer's last vendored copy | No PR — short-circuits in the script: [<consumer>] up-to-date, skipping. |
| Clone / push / PR fails for a single consumer | Error logged for that consumer, the loop continues, other consumers still get their PRs. The workflow step exit code is whatever the script returns; failures are visible in the summary. |
- Not an auto-merge. It opens PRs; humans merge them. Branch protection
on each consumer requires a PR for every commit to
main, so this is the only shape the workflow can take. - Not a polling webhooks consumer. It runs on push. The drift check on the consumer side runs on PR. Together they cover "publisher changed" (this workflow) and "consumer PR might be stale" (drift check).
- Not idempotent across re-runs. A re-run does no harm — the script compares content before committing — but you'd typically just wait for the original run to finish.
- Not the only place
docs/standards/gets written. Consumers can runnode scripts/vendor-standards.mjs --local(or fork the vendor-standards script) to seed the directory for the first time, before the workflow has ever fired for them. The sync header is what makes the drift check happy either way.