This doc states the contract that /.github/workflows/ci.yml is required to
keep. The contract exists because Lore is a public repository: every external
fork's pull request runs ci.yml against code the maintainers have not seen,
under the fork's identity, with no access to repo secrets.
A CI step that needs a Notion token, a real vault, an internal fixture, or any other secret will fail (or worse, silently skip) on a fork PR. The contract below keeps every step inside that envelope.
Repository bindings. .github/CODEOWNERS declares @Iron-Ham
ownership for .github/, .github/workflows/, and this doc. The
workflow-lint job in ci.yml runs actionlint and zizmor on every
push to main and every pull request. These repository artifacts are
review-routing and CI signals unless branch protection or repository
rulesets require code-owner review and the named status checks before
merge.
Upstream reference. This contract is project-specific, but the underlying threat model and patterns it codifies come from GitHub's Security hardening for GitHub Actions guide. When a new footgun emerges that this doc hasn't yet captured, the upstream guide is the fallback reference.
If you are adding a CI step, read the Rules for new CI steps section first. If your step legitimately needs internal state, move it to a separate workflow that does not run on
pull_request(see the Adding internal-state workflows section for the safe shapes — a baregithub.repository == 'makenotion/lore'check is not fork-safe when the workflow runs onpull_request).
Every step in ci.yml is fork-safe. The table lists what each step does and
why it stays inside the envelope.
| Step | Command | Needs token? | Needs network? | Needs internal fixture? | Why it's fork-safe |
|---|---|---|---|---|---|
| Checkout | actions/checkout (SHA-pinned, see rule #6) |
No | Public Git over HTTPS | No | Reads the PR's own commit; no secrets injected; persist-credentials: false prevents the checkout token from being written into git config. |
| Install actionlint | curl the pinned actionlint Linux release archive, verify SHA-256, and add the binary to PATH |
No | Public GitHub release asset | No | Downloads a public, versioned static-analysis binary and verifies the archive checksum before execution. |
| Run actionlint | actionlint .github/workflows/*.yml |
No | No | No | Pure static analysis of committed workflow files. |
| Install zizmor | curl the pinned zizmor Linux release archive, verify SHA-256, and add the binary to PATH |
No | Public GitHub release asset | No | Downloads a public, versioned static-analysis binary and verifies the archive checksum before execution. |
| Run zizmor | zizmor --min-severity medium --format github --no-progress .github/workflows |
No | No | No | Pure static analysis of committed workflow files; unsuppressed medium-or-higher findings fail the job. |
| Setup Node.js | actions/setup-node (SHA-pinned, see rule #6) |
No | Public npm mirror | No | Standard action; pulls Node 20 binary. |
| Install dependencies | npm ci |
No | Public npm registry | No | No .npmrc, no @makenotion-scoped runtime deps. prepare script (tools/install-git-hooks.mjs) returns early when CI=true. |
| Format check | npm run format:check (prettier --check src/) |
No | No | No | Pure static formatting check. |
| Version sync check | npm run version:check (node tools/check-version-sync.mjs) |
No | No | No | Pure static consistency check that package, MCP handshake, CLI, and Notion User-Agent version literals match. |
| Lint | npm run lint (eslint src/) |
No | No | No | Pure static analysis. |
| Typecheck | npm run typecheck (tsc --noEmit) |
No | No | No | Pure static analysis. |
| Test | npm test (vitest run) |
No | No | No | Tests use fixture-backed services. tests/setup-runtool-flag.ts pins all RunTool flags to 0 so no test path can accidentally hit Notion. |
| Build | npm run build (tsup) |
No | No | No | Local bundler. |
| Eval starter suite | node dist/cli.js eval run evals/suites/lore-core.yaml … |
No | No | No (synthetic) | Runs the retrieval runner (see below). Synthetic YAML fixtures, no Notion calls. |
| Upload eval artifact | actions/upload-artifact (SHA-pinned, see rule #6) |
Ambient GITHUB_TOKEN only |
GitHub API | No | The ambient per-run GITHUB_TOKEN is automatically scoped to this run; on a fork PR it is read-only by default and lifetime-bound to the run. The workflow declares permissions: contents: read at top level so the token is least-privilege regardless of repo defaults. |
The concurrency block keys on github.ref and cancels stale runs; that's
also fork-safe — for pull_request, github.ref is refs/pull/<n>/merge
and naturally isolates per-PR. (For other triggers github.ref is not
inherently per-PR; this workflow only fires on push: main and
pull_request, both of which are safe here.)
CI invokes the eval CLI with the evals/suites/lore-core.yaml suite, which
declares runner: retrieval (see src/eval/runner.ts for the dispatch and
docs/evals.md for the runner taxonomy). The retrieval runner is
contractually hermetic:
- It loads YAML fixtures from
evals/memory/andevals/baselines/. These fixtures are explicitly synthetic — every fixture file begins with a banner stating "Synthetic fixture. Do not copy production vault content into eval fixtures." None of the fixture ids (decision/auth-model,note/general-ui-polish, …) exist in any real vault. - It calls a
fixtureWakeUpServices()factory (src/eval/runner.ts) that returns an in-memoryWakeUpServicesmock. The realinitServices()path — the only code path that readsNOTION_API_TOKEN,auth.json, or.lore.yaml— is never invoked. - The baseline JSON is read with
readFileand validated with Zod; no network access.
The Notion-backed runner (--runner notion --project <SandboxProject>)
does require a real token and a sandbox vault, but the CI workflow does
not pass --runner notion or --project, so that branch never fires.
Anyone changing the eval CI step who wants live-vault coverage should add
it to a separate workflow that does not run on pull_request (see
Adding internal-state workflows),
not to ci.yml.
publish.yml only runs on release: published, so a fork pull request cannot
run the job. The job has only contents: read plus id-token: write; npm
exchanges the GitHub Actions OIDC identity for a short-lived publish credential.
Before an OIDC release, the package owner must configure
@notionhq/lore's Trusted Publisher for GitHub organization makenotion,
repository lore, workflow filename publish.yml, and the npm publish
action. npm requires the package to exist before configuring that relationship,
so a new package needs one manual publication with interactive 2FA. The release
workflow treats an already-published version as success, allowing that bootstrap
version to receive a matching GitHub Release without attempting a duplicate
publish.
tools/install-git-hooks.mjs is wired into the prepare npm script and runs
on every npm ci — including in CI. It guards against running in CI by
returning early when process.env.CI === "true":
if (env["CI"] === "true" || env["LORE_SKIP_GIT_HOOK_INSTALL"] === "1") {
return
}GitHub Actions sets CI=true automatically, so the hooks installer is a
no-op on every CI run, fork or otherwise.
.lore.yaml is local-only — each clone copies .lore.example.yaml to
.lore.yaml and fills in values from team onboarding docs. The repo
enforces this with two complementary layers. This invariant is the current
policy even for credential-free shared vault config and supersedes older
changelog notes that described intentionally committed config:
- Pre-commit guard.
.githooks/pre-commitinvokesnode tools/check-lore-config.mjs --staged, which rejects any staged.lore.yamlindex entry regardless of content (keying offgit ls-files --cached --error-unmatch). The hook is installed bytools/install-git-hooks.mjsduringnpm install. CI does not invoke it directly — pre-commit hooks run on the contributor's machine, andLORE_SKIP_GIT_HOOK_INSTALL=1plus theCI=trueguard in the installer skip the install path on CI runners anyway. - CI-side repo invariant. The
src/config-guard.test.tsinvariant for an untracked root.lore.yamlruns in the standard test suite and fails the build if.lore.yamlis ever tracked at the repo root again, regardless of how it slipped in (rebase, cherry-pick, manual sequencer). This is what catches a bypass that the pre-commit hook missed.
Fork contributors do not need to install the hooks; they commit to their own branches, and the repo-invariant test runs against the merge candidate.
Before adding a new step to ci.yml, confirm all seven of these:
- No repo secrets. The step does not reference
secrets.*other than the ambientGITHUB_TOKEN(which is auto-scoped per-run, even on forks). - No live Notion access. The step does not call any CLI/MCP path that
ends up in
initServices()orgetNotionClient(). If the step exercises retrieval, it must use--runner retrieval. - No internal-only fixtures. Any fixture under
evals/,tests/,examples/, etc. that the step reads must be committed to the repo and either explicitly synthetic or sourced from public data. - No privileged
prepare/postinstallwork. Don't addprepareorpostinstallhooks that require interactive state, a writable git config, or non-public network access. The existingtools/install-git-hooks.mjsshort-circuits onCI=true; new install-time hooks must take the same posture. - No privileged PR-head checkout.
pull_request_targetandworkflow_runrun with write tokens and elevated permissions. The single most common GitHub Actions supply-chain footgun combinespull_request_target,actions/checkoutofgithub.event.pull_request.head.*, and repo secrets in the same job. If you think you need this, ask the security owner first. - Third-party actions are pinned to a full commit SHA, not a floating
tag. Include a trailing
# v<x.y.z>comment so reviewers can verify the pin without leaving the file. Floating tags (@v4) leave the workflow exposed to SHA-shifting / retagging —publish.ymlalready meets this bar;ci.ymlmust too. To verify a SHA matches its claimed tag before committing the pin, check the upstreamreleases/tag/<v>page or rungh api repos/<owner>/<action>/git/ref/tags/<v> --jq .object.shaand confirm it matches. - Workflow declares an explicit top-level
permissions:block, and no job widens it beyond what that job needs. Default topermissions: contents: read. The repo-default token permissions are org/repo-policy-dependent; an explicit top-level block guarantees least-privilege regardless of policy drift.
If a step legitimately can't satisfy rules 1–3 — for example, a nightly
live-vault eval — put it in a separate workflow file. The safest shape is
to keep that workflow off pull_request entirely, so fork PRs cannot ever
trigger it:
on:
schedule:
- cron: "0 7 * * *"
workflow_dispatch:
permissions:
contents: read
jobs:
nightly-live-eval:
if: github.repository == 'makenotion/lore'
...github.repository == 'makenotion/lore' is not by itself a fork-safe
gate when the workflow runs on pull_request. A fork PR opened against
the base repo runs in the base repo's context: github.repository is
still makenotion/lore, and the guard evaluates true. Use one of these
two shapes when a workflow needs to stay on pull_request but still skip
fork PRs:
# Either: forbid pull_request entirely on the secret-backed workflow.
on:
push:
branches: [main]
workflow_dispatch:# Or: combine the canonical-repo check with a PR-head check.
if: >
github.repository == 'makenotion/lore' &&
(github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.full_name == github.repository)The combined guard handles the case where a fork PR runs in the base repo's
context: github.event.pull_request.head.repo.full_name resolves to
<fork-owner>/lore, which differs from github.repository, so the guard
short-circuits before any privileged step runs.
Two quick checks before opening a PR that touches CI:
# 1. The whole ci.yml pipeline, with no supported Notion auth source
# available and CI=true so the prepare hooks installer takes its no-op
# branch.
#
# The chain runs inside a single `bash -c` so the env scrub and CI=true
# apply to every command, not just the first one. `env -u VAR cmd1 &&
# cmd2` only scrubs cmd1; cmd2 inherits your caller environment and the
# self-test can falsely pass on a maintainer machine that has tokens set.
# Isolated HOME and XDG_CONFIG_HOME also hide ntn auth.json, which is a
# supported fallback auth source when NOTION_API_TOKEN is unset.
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
mkdir -p "$tmp/home" "$tmp/.config"
env -u NOTION_API_TOKEN -u GITHUB_TOKEN \
HOME="$tmp/home" XDG_CONFIG_HOME="$tmp/.config" CI=true \
bash -c '
set -euo pipefail
npm ci
npm run format:check
npm run lint
npm run typecheck
npm test
npm run build
node dist/cli.js eval run evals/suites/lore-core.yaml \
--out evals/results/lore-core-ci.json \
--min-lift 0.5 --max-harm 0.0 \
--baseline evals/baselines/lore-core.json
'If any step in that pipeline fails when no supported Notion auth source is available, the step has regressed the fork-safety contract.
# 2. Confirm the eval suite still declares the retrieval runner.
grep '^runner:' evals/suites/lore-core.yaml
# Expected: runner: retrievalWhen the repo first goes public, the maintainer who reviews the inaugural external fork PR should confirm:
Happy-path:
- The "CI" check on the PR shows green or skipped — not failed with an auth error.
- The "Upload eval artifact" step ran and attached an artifact (proves the
eval reached the end on a fork's
GITHUB_TOKEN).
No silent secret leak:
- The run logs contain no
***redactions. GitHub redacts any secret value it sees in step output; redactions in a fork-PR run mean a secret was expanded into the environment, which violates the contract. - Dump the relevant env on the fork-PR run and confirm
NOTION_API_TOKENand any org-level secret are empty. An ad-hoc step likerun: 'echo "notion=${NOTION_API_TOKEN:+SET}"'(without the variable value itself) proves the absence without risking a leak if the contract has already broken. The GitHub-native equivalent is a step gated onif: ${{ secrets.NOTION_API_TOKEN != '' }}that fails the run if it evaluates true; that form sidesteps echo entirely.
No internal-only workflow fired:
- No workflow gated on
github.repository == 'makenotion/lore'ran. - No
workflow_run-shaped workflow fired off the fork'spull_requestcompletion. If a future workflow listens forworkflow_runfromCI, re-run this check —workflow_runruns in the base repo's context with the base repo's permissions, and it's a known leak path if it consumes fork outputs.
Record the result on the public-launch issue.