Skip to content

CI import: design + implementation (TNT-208 + TNT-209)#153

Draft
cevian wants to merge 10 commits into
mainfrom
mat/ci-import-design
Draft

CI import: design + implementation (TNT-208 + TNT-209)#153
cevian wants to merge 10 commits into
mainfrom
mat/ci-import-design

Conversation

@cevian

@cevian cevian commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Summary

Design and full implementation for moving repo→memory imports from the per-clone post-commit git hook to a GitHub-Actions-driven import on push to the default branch, treating TNT-208 (git history) and TNT-209 (docs) as one system. The design doc (CI_IMPORT_DESIGN.md, first commit-group) was reviewed iteratively; the implementation follows its §12 plan as sequenced, individually-reviewable commits.

The commits

  1. fix(cli): correct stale importers/git.ts module header — the header described the retired deterministic-id / DO NOTHING scheme.
  2. feat(cli): me import ci orchestrator — the one command the workflow calls: git phase (incremental; first run backfills) then docs phase (default markdown globs, prune on, clean skip when no docs match). Fail-fast, per-phase summaries, --dry-run previews locally exactly what CI will do. New strict import: block in .me/config.yaml (git/docs toggles, docs_include/docs_exclude, service_account). Also hardens the e2e harness to run inside a live agent session (strips harness markers in cliEnv(); the git() helper now uses a passed env as the base — overlaying re-leaked stripped markers into hook-spawned processes).
  3. feat(server): authority denials name the space admins — admin-gated FORBIDDEN errors now carry data.admins: [{email}] (effective admins via listSpacePrincipals, the same predicate enforce_last_admin counts; a user principal's name IS its email). Enriched gates: requireSpaceAdmin/requireTreeOwner (memory RPC) and the two service-account gates (user RPC). CLI renders the contacts under the error; structured output carries admins.
  4. feat(cli): me project ci + init step — managed-file workflow scaffold (runtime default-branch gate, --key-name), secret-presence pivot (repo + org-provided secrets via gh), never-implicit provisioning (--create-service-account / TTY yes), verify mode for a named identity, mint-only-into-gh secret set (key never displayed), --rotate-key, and the denied-non-admin escalation block (two admin commands + self-serve retry). Init wraps it as the ci-workflow step; pending states never abort init.
  5. feat(cli)!: remove me import git-hook — removed-command stub; import-git-hook.ts reduced to marker-based cleanup helpers; me project ci strips an installed legacy block once CI credentials are in place (never earlier — avoids a capture gap); init's git-import/git-hook steps dropped.
  6. docs: CI importme-import.md (+ removal notice), me-project.md, project-config.md.
  7. ci: dogfood workflow in this repo — byte-exact scaffold; see activation note below.

Verification

  • ./bun run check:full (typecheck + lint + unit + integration + e2e against local Postgres) green.
  • New coverage: e2e 8h (me import ci: dry-run, toplevel anchoring, idempotent re-run, prune-on-delete, .me phase gating, no-docs skip, non-repo error, --json rejection), 8i (me project ci full flow against a fake gh for deterministic secret state: headless denial, provisioning with SA+grant+key-into-secret, maintenance re-run, committed-identity verify, rotation), 8f (removed stub + legacy-hook migration), 10d (denied me service create renders admin contacts), plus integration tests for the enriched FORBIDDEN on both RPC endpoints and unit tests for the scaffold/parsing/grant-check helpers.

⚠️ Operator steps before merge (dogfood activation)

The committed workflow is inert-but-loud until, on this repo:

  1. a .me/config.yaml pinning this repo's space and a shared tree is committed (I didn't guess your dogfooding space), and
  2. me project ci --create-service-account is run once (provisions the SA, grants write at the tree, sets the ME_API_KEY repo secret).

Until then, pushes to main will show the import run failing at me import ci — visible-pending rather than silent, but if you'd rather not have red runs pre-activation, drop commit 7 or gate it.

Questions / uncertainties encountered while building

  1. AdminContact is {email} only, not {name, email}. The design doc said {name, email} with email joined from auth.users — but core SQL never crosses into the auth schema, the RPC handlers have no auth-store handle, and for user principals core.principal.name IS the email. Shipping the email (the actual contact) avoided threading auth-schema access through the server for a display name. The type is an object so name can be added compatibly later. OK?
  2. Every memory-RPC FORBIDDEN now carries admin contacts, not just the SA/grant paths — requireSpaceAdmin/requireTreeOwner back groups, roster, invites too. I judged admins-to-members exposure fine (it's "contact your admin", members only), but it's a broader surface than the design's minimum.
  3. me import ci rejects --json/--yaml (two phases, one stdout). The alternative — refactoring both importers to return structured results for a merged document — felt like scope creep. Run the phases separately for structured output.
  4. Verify mode degrades for non-admins — in two deliberate layers (tightened by a follow-up commit after review of this exact question). Existence is checked via principal.resolve (the targeted any-member lookup), so any member gets a true answer — the first cut used serviceAccount.list, which is silently filtered for non-admins and produced a false "does not exist" for a shared org SA; e2e 8i now pins the non-admin case. The grant check (grant.list on another principal) stays admin-gated, so a non-admin gets "couldn't verify (requires authority)" + verified: false + exit 0 rather than a hard answer — degrade-not-fail because the setup may be perfectly correct (org SA, parent-level grant) and the verifier's privileges shouldn't block it. The first workflow run is the real end-to-end verification either way.
  5. Secret-presence "unknown" (no gh, not authenticated, or no repo access) blocks the no-flag path with guidance rather than guessing. gh api repos/{nwo}/actions/organization-secrets failures are treated as "no org secrets" only after the repo-level check succeeded — is that the right 403-vs-404 heuristic for org-secret visibility?
  6. Headless --service-account grant-fix applies without a prompt (a named SA = stated intent, per the design). Flagging in case that feels too eager for a verify-shaped run.
  7. The dogfood question above (red runs pre-activation vs dropping commit 7).
  8. e2e harness hardening (commit 2) changes cliEnv()/git() for every test — needed to run the suite from inside any live agent session at all (pre-existing gap: inherited CLAUDECODE/AI_AGENT markers tripped the spawned CLI's failsafe). CI behavior is unchanged (markers absent there), but it touches shared harness code.
  9. Minted key names are ci-import-<rand4> (api-key names are unique per principal, so a fixed name would break rotation). Old keys accumulate until revoked — the rotate path prints the revoke commands but doesn't auto-revoke (the old key may still be in use elsewhere, e.g. a second repo sharing the SA).

Tickets

  • TNT-208 — redo the git integration from a git hook to a GitHub workflow
  • TNT-209 — CI/CD (GitHub Actions) integration with import docs

🤖 Generated with Claude Code

https://claude.ai/code/session_01KrraND2hq3NVUSS38nhP9d

Design for replacing the per-clone post-commit git hook with a
GitHub-Actions-driven import on push to the default branch, covering
git history and docs as one system: a scaffolded repo-agnostic
workflow, a `me import ci` orchestrator, `me project ci` setup with
service-account provisioning, and removal of `me import git-hook`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KrraND2hq3NVUSS38nhP9d
Copilot AI review requested due to automatic review settings July 10, 2026 20:16

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new design document (CI_IMPORT_DESIGN.md) describing how to move repo→memory imports from a local git hook to a GitHub Actions workflow that runs me import ci on pushes to the default branch, using service accounts as the team-owned identity.

Changes:

  • Introduces a combined TNT-208/TNT-209 CI import architecture (workflow shim → me import ci orchestrator → existing importers).
  • Specifies service-account-based identity + provisioning flow (me project ci) including org-secret and per-repo variants.
  • Documents operational behaviors (default-branch gating, full-history backfill, docs prune, idempotency, and failure UX).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread CI_IMPORT_DESIGN.md
workflow_dispatch: {} # manual backfill / re-run

concurrency:
group: me-import
cevian and others added 7 commits July 13, 2026 08:16
The header described a retired identity scheme (deterministic UUIDv7
keyed by git:<tree>:<sha>, ON CONFLICT (id) DO NOTHING). The real
behavior is (tree, name=sha) idempotency with onConflict: "replace"
and random-tailed uuidv7At ids used only for recency ordering.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KrraND2hq3NVUSS38nhP9d
The one command the scaffolded GitHub workflow calls: run the
project's configured imports from the repo toplevel — git history
(incremental; first run backfills), then docs (default markdown
globs, prune on, clean skip when the repo has no matching docs).
Phases are fail-fast and each renders its own summary; --dry-run
previews locally exactly what CI will do.

An optional `import:` block in .me/config.yaml (strict, like the
rest of the schema) shapes the run: git/docs phase toggles,
docs_include/docs_exclude globs, and service_account (read by the
upcoming `me project ci` setup command, never at import time).

Also hardens the e2e harness for running inside a live agent
session: cliEnv() now strips harness markers (AI_AGENT, CLAUDECODE,
…) and the injected-contract vars so the spawned CLI behaves as a
plain human/CI invocation instead of tripping the harness failsafe,
and the git() helper uses a passed env as the BASE rather than an
overlay (overlaying re-leaked the stripped markers into hook-spawned
processes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KrraND2hq3NVUSS38nhP9d
An admin-gated denial was a dead end: a non-admin can't enumerate the
roster to find whom to ask (principal.list is admin-only). Now the
server enriches such FORBIDDEN errors with the space's effective
admins — the same predicate enforce_last_admin counts, via
listSpacePrincipals — as data.admins [{email}] (a user principal's
name IS its email). Computed server-side, only on a denied attempt,
only ever reaching space members; enrichment is best-effort and never
turns a denial into an internal error.

Enriched gates: requireSpaceAdmin + requireTreeOwner (memory RPC —
grants, groups, roster, invites) and requireSpaceAdminById +
requireServiceAccountManager (user RPC — service accounts and their
keys). The CLI renders the contacts under the error in text mode and
carries them as `admins` in structured output.

For `me project ci` (next commit) this is the escalation path: the
denied provisioning run prints the admins and the exact commands to
ask them to run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KrraND2hq3NVUSS38nhP9d
The setup side of the CI import: scaffold the GitHub Actions
workflow and provision the service-account credentials.

- Workflow: managed-file semantics (marker line; a hand-maintained
  file is never silently clobbered). The scaffold is repo-agnostic —
  the default branch is discovered at runtime by the workflow's own
  job-level `if` (a scaffold-time name silently rots on rename) — so
  the only variable content is the secret name and, for servers a
  bare CI checkout couldn't resolve, a baked-in ME_SERVER.
- Identity/key phases pivot on secret presence (repo secrets via gh,
  org-provided via the repo's organization-secrets endpoint).
  Provisioning is never implicit: --create-service-account or an
  interactive yes. A named identity (--service-account or the .me
  import.service_account) turns a secret-present run into verify
  mode (SA exists + write at the project tree; missing grant is
  offered/applied; missing SA is a hard inconsistency error).
- Keys are minted ONLY when piped straight into `gh secret set` —
  never displayed or stored; without gh the exact commands are
  printed and nothing is minted. --rotate-key mints a fresh
  (collision-free) key for an existing SA.
- An admin-gated denial renders the escalation block: the effective
  admins (from the enriched FORBIDDEN) + the two commands to ask
  them to run (--admin <you> seeds the bound admin group so the
  retry is self-serve) + "then re-run me project ci".
- Requires a committed .me pinning space and a non-home tree — a
  service account has no `~`, so a home-rooted tree can never work
  in CI.
- Init grows a thin `ci-workflow` step wrapping the command; a
  pending end-state (ask-your-admin, no secret yet) never aborts the
  wizard's remaining steps.

e2e drives the full flow against a fake `gh` (deterministic secret
state): scaffold-only denial headless, explicit provisioning with
SA + grant + key-into-secret, scaffold-only maintenance re-run,
committed-identity verify, and rotation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KrraND2hq3NVUSS38nhP9d
The local post-commit hook imported whatever HEAD was — unmerged and
rebased commits landed in the tree keyed by (tree, sha) forever — it
ran per-clone with the committing human's credentials, and it failed
silently. CI imports replaced it (TNT-208).

- `me import git-hook` is now a removed-command stub (hidden; accepts
  the old flags so its message prints instead of a parse error),
  pointing at `me project ci` or manual block deletion.
- import-git-hook.ts is reduced to the marker-based cleanup helpers
  (installedHookFile / stripHookBlock / removeHookBlock).
- `me project ci` migrates installed hooks: it strips the managed
  block once CI credentials are in place — never earlier, which
  would open a capture gap between "hook gone" and "CI not yet
  importing" — with a TTY confirm, or a note when credentials are
  still pending.
- Init's git-import (local backfill) and git-hook steps are gone:
  the workflow's first run backfills main's full ancestry under the
  service account.
- docs/cli/me-import.md's section is replaced by a removal notice
  with the one-liner for people who genuinely want local capture
  (`me import git` in their own hook).

BREAKING CHANGE: `me import git-hook` and `me project init`'s
--skip-git-import/--skip-git-hook flags are removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KrraND2hq3NVUSS38nhP9d
- me-import.md: the `me import ci` orchestrator (phases, prune-on
  semantics, dry-run parity, the import: config block, no structured
  output).
- me-project.md: `me project ci` (workflow scaffold + managed-file
  semantics, secret-presence gating, provisioning with mint-into-
  secret, the denied-non-admin escalation, hook migration); the init
  checklist's Git-history rows replaced by the CI-import row.
- project-config.md: the strict `import:` block (phase toggles, docs
  globs, service_account and when committing it matters).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KrraND2hq3NVUSS38nhP9d
The dogfood scaffold, byte-exact from `me project ci`'s renderer.
Inert until an operator completes activation out-of-band: commit a
.me/config.yaml pinning this repo's space + shared tree, and run
`me project ci --create-service-account` (provisions the service
account, grants write at the tree, and sets the ME_API_KEY repo
secret). Until then pushes to main will show the run failing loudly
at `me import ci` — by design, setup-pending is visible, not silent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KrraND2hq3NVUSS38nhP9d
Copilot AI review requested due to automatic review settings July 13, 2026 07:46
@cevian cevian changed the title docs: CI import design (TNT-208 + TNT-209) CI import: design + implementation (TNT-208 + TNT-209) Jul 13, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 30 out of 30 changed files in this pull request and generated 3 comments.

Comment on lines +361 to +364
output(structured, fmt, () => {
clack.log.info("No matching docs — skipping the docs import");
});
return;
Comment on lines +28 to +33
try {
const principals = await core.listSpacePrincipals(spaceId, "u");
return principals.filter((p) => p.admin).map((p) => ({ email: p.name }));
} catch {
return [];
}
Comment thread packages/cli/util.ts
Comment on lines 570 to 581
} else {
output(code ? { error: msg, code } : { error: msg }, fmt, () => {});
output(
{
error: msg,
...(code ? { code } : {}),
...(admins ? { admins } : {}),
},
fmt,
() => {},
);
}
process.exit(1);
serviceAccount.list is silently FILTERED for non-admins (only the
accounts the caller administers), so a plain member verifying a
committed import.service_account — the org shared-SA flow — got a
false "does not exist" hard error, with advice that dead-ends in a
name conflict. principal.resolve is the targeted any-member lookup
built for exactly this; both the verify-phase and provisioning-phase
existence checks now use it (kind 's'), so a non-admin's verify
degrades honestly on the admin-gated grant check (verified:false,
exit 0) instead of lying about existence, and provisioning finds an
SA someone else created rather than re-attempting a doomed creation.

e2e 8i grows the non-admin verify case that would have caught this.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KrraND2hq3NVUSS38nhP9d
Copilot AI review requested due to automatic review settings July 13, 2026 08:55

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 30 out of 30 changed files in this pull request and generated 1 comment.

Comment on lines 466 to +474
if (opts.prune) {
if (unique.length === 0) {
pruneRefused =
"empty walk — a wrong directory or over-narrowed --include must not delete the whole corpus";
// Under skipIfEmpty (orchestrated runs) an all-empty walk skips the
// prune silently instead: deleting the whole previously-imported corpus
// because every file emptied out is exactly what the refusal guards.
if (opts.skipIfEmpty !== true) {
pruneRefused =
"empty walk — a wrong directory or over-narrowed --include must not delete the whole corpus";
}
"Couldn't verify '<sa>' locally (requires admin/owner authority).
Run the workflow on GitHub to verify." — says where the real
verification happens instead of vaguely gesturing at it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KrraND2hq3NVUSS38nhP9d
Copilot AI review requested due to automatic review settings July 13, 2026 09:43

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 30 out of 30 changed files in this pull request and generated 2 comments.

Comment on lines +359 to +364
errors: [],
};
output(structured, fmt, () => {
clack.log.info("No matching docs — skipping the docs import");
});
return;
Comment thread packages/cli/util.ts
Comment on lines +571 to 582
output(
{
error: msg,
...(code ? { code } : {}),
...(admins ? { admins } : {}),
},
fmt,
() => {},
);
}
process.exit(1);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants