feat(docs-sync): gh-aw hub, agent knowledge base and release notes (IP-2864, IP-2863, IP-2866) - #18
Conversation
…P-2864, IP-2863, IP-2866) The docs-repo half of the docs-sync pipeline from the IP-2745 spike. Inert until the credentials in IP-2861 exist and this lands on the default branch. DOCS-4 (IP-2864) — .github/workflows/docs-sync.md + compiled docs-sync.lock.yml Triggered by repository_dispatch (docs-sync) from desci-infra on release. Pinned to gh-aw v0.85.4, outside the 0.68.4-0.71.3 billing bug. The agent job is contents:read; every write happens in the separate sanitized safe-outputs job. Verified in the generated lock. A deterministic relevance gate runs in pre_activation, before the agent job exists, so a release touching no documented surface costs zero AI credits. It uses one compare API call, not a clone. skip-if-match cannot do this - it evaluates GitHub search queries, not changed paths. stop-after +14d bakes the DOCS-7 pilot expiry to an absolute timestamp. DOCS-3 (IP-2863) — .github/prompts/docs-sync.md Page-to-source map, house style, guardrails and the PR body contract. Points the agent at hand-authored sources and away from graphql/autogen and prisma/generated, which are build artefacts. DOCS-6 (IP-2866) — release-notes/ + SUMMARY.md Consumer-facing per-version notes, complementing the thematic api-reference/changelog.md. Seeded from release 1.0.14, whose isSuccess removal is verifiable in PR #652 of desci-infra. DOCS-2 (IP-2862) is materialized here as close-older-pull-requests: true. A rolling sync PR is not achievable - create-pull-request calls pulls.create unconditionally and can never update an existing PR. Rationale and the alternatives are recorded in desci-infra/docs/docs-sync-write-strategy.md. Security review of new restricted secrets, per gh-aw safe update mode: ANTHROPIC_API_KEY - engine key, same org key the other repos already use. DOCS_SYNC_APP_KEY - private key of a GitHub App holding contents:read on desci-infra and nothing else. Neither grants write on any source repo. The compiled lock excludes both from the agent container and redacts them in logs. Verified: gh aw compile, gh aw validate --strict, gh aw lint all clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Drops the hand-written 1.0.14 entry. The entry-format template in release-notes/README.md is the whole specification; a worked example was a marginal gain and it was the only content in this PR asserting facts to external integrators on a public site. The first real entry now comes from a pilot run, which also exercises the pipeline properly. Removes the same assumption from the agent knowledge base, which told the agent to imitate examples that no longer exist. Note the 1.0.14 breaking change this entry described is real and still undocumented: 43 references to the removed isSuccess field remain across 9 reference pages. Tracked separately - it needs the reference pages corrected, not a release-notes entry. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e, close IP-2864 AC gaps - Re-pin v0.85.4 -> v0.86.2 and record the pin + its reason in the workflow header (IP-2864 AC). v0.86.2 carries the Claude-harness retry fix (github/gh-aw#51793) whose trigger scenario is exactly this workflow's engine + strict bash allow-list, plus enforced secret redaction in step summaries and patch artifacts (#50777/#50778). Verified behaviour-neutral for the DOCS-2 write strategy: safe-output schemas and the close_older_pull_requests handler are unchanged between the two versions. - Release notes now travel hub-side, not in the dispatch payload: a pre-agent step mints a token from the read-only source App (Releases API is covered by contents: read - no permission change) and writes the body to ./source/RELEASE_NOTES.md. The payload lands in a public repo's workflow run, and source release bodies now carry real content naming private infrastructure (desci-infra 2.0.0, 2026-08-12). Contract change lands in desci-infra#675 in the same breath. - Add the claude-code-action fallback note (IP-2864 AC), flag it as unvalidated for repository_dispatch. - Relevance gate: add docs/service-auth.md (mapped for authentication and roles pages, previously unreachable); document that the gate is a deliberate triggering subset of the map, with the ride-along rows named in the knowledge base. - Knowledge base: account for the previously unmapped pages (technical-deep-dive/onchain-lab.md, module-registry/**, data/README.md, and the orphan api-reference/IPNFT-api.md) in the out-of-scope list. - stop-after refreshed at compile: expiry is now 2026-08-28 10:16:52. If this PR has not merged by then, run `gh aw compile --refresh-stop-time` again before merging or the pilot arrives expired. Recompiled with gh-aw v0.86.2; `gh aw validate --strict` and `gh aw lint` both clean; agent job verified still contents: read only, bots allow-list and max-ai-credits unchanged in the regenerated lock. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
0xmme
left a comment
There was a problem hiding this comment.
Automated review of this PR in conjunction with moleculeprotocol/desci-infra#675 (the spoke half). 12 inline findings here, 3 more on the spoke PR.
The headline: the pipeline cannot work as compiled. The bash: allow-list entries lack :*, so they compile to exact-match permission rules and every argument-bearing command is denied — and even once that is fixed, git -C source diff (what both prompt files instruct) matches no derivable prefix rule, and there is no permitted path to run git against the ./source checkout at all. Those two need fixing together.
Two findings are cases where a rule these docs themselves state is not upheld by the code in the same PR: the release-notes exposure (the field was dropped from client_payload, but the same body is fetched into a public repo's run and uploaded as a public artifact + step summary) and the client_payload-into-with: interpolation (against the contract's own line 443). Worth fixing the code rather than softening the docs.
The three new docs/*.md design records on the spoke PR are genuinely good — this review leaned on them heavily, which is how several of these were found.
|
|
||
| tools: | ||
| edit: | ||
| bash: ["git diff", "git log", "git show", "git status", "ls", "cat", "rg"] |
There was a problem hiding this comment.
The compiled allow-list denies every command the agent actually needs.
These entries are written without :*, so they compile to exact-match Claude permission rules:
--allowed-tools '...,Bash(git diff),Bash(git log),Bash(git show),Bash(git status),Bash(cat),Bash(ls),Bash(rg),...'
(docs-sync.lock.yml:964)
In Claude Code Bash(git diff) matches only the literal string git diff. Argument-bearing commands need Bash(git diff:*) — which is exactly the form gh-aw itself emits for its own entries on the same line (Bash(git add:*), Bash(git commit:*)).
So on a real run the agent's first action, git diff <base_sha> <sha>, returns permission_denied, as does cat <path>, ls <dir>, rg <pattern>. The run burns credits, produces no diff, and either opens a PR with no evidence behind it or none at all.
Worth noting: the v0.86.2 pin comment at lines 40-46 describes the retry-budget bug triggered by exactly this permission_denied shape. The pin was bumped to survive the symptom rather than the cause being fixed.
Fix: add :* to each of the seven entries — and see the related comment on line 225, because that alone is not sufficient.
There was a problem hiding this comment.
Fixed in fc55a75: all seven entries now carry :*, and git -C:* is added per the companion finding on line 225. Verified in the recompiled lock — the agent's --allowed-tools now reads Bash(cat:*),…,Bash(git -C:*),Bash(git diff:*),Bash(git log:*),Bash(git show:*),Bash(git status:*),Bash(ls:*),Bash(rg:*).
|
|
||
| Then: | ||
|
|
||
| 1. Diff the release: `git -C source diff <BASE_SHA> <SRC_SHA>`. |
There was a problem hiding this comment.
git -C source diff can never match a prefix rule derived from "git diff".
Both this line and the knowledge base (.github/prompts/docs-sync.md:23,36) instruct the agent to use git -C source diff. Claude's Bash matcher is a literal prefix match: git -C source diff ... does not start with git diff, so it is denied even after the :* fix on line 188.
The obvious alternative is also closed off:
cd source && git diff ...—cdis not in the allow-list, and the compound form is rejected anyway.- a bare
git diff— Claude's cwd is$GITHUB_WORKSPACE, i.e. the docs repo, so this diffs the wrong repository.
There is currently no permitted way to run git against the ./source checkout at all, which is the pipeline's primary signal.
Fix: add git -C:* (or equivalent) to the allow-list, and make this line and the two prompt-file references agree with whatever form is permitted.
There was a problem hiding this comment.
Fixed in fc55a75: git -C:* added to the allow-list, and the tools list, the workflow body and the knowledge base now all agree on the git -C source … form (a comment in tools.bash pins that agreement for future edits).
| # missing release or failed mint leaves an empty file, which the prompt already | ||
| # treats as normal. These steps run on the runner, outside the agent container, | ||
| # so the token never enters the agent's environment. | ||
| pre-agent-steps: |
There was a problem hiding this comment.
Dropping release_notes from the payload does not achieve the stated security property.
docs-sync-dispatch-contract.md:398-415 justifies the removal because client_payload "lands in a workflow run of a public repository, and release bodies now carry real content that names private infrastructure" (release 2.0.0: 9,381 chars naming Aurora subnet moves, operator scripts, CloudFormation logical ids, incident dates), and concludes that "the notes stay inside the run's workspace and never appear in the public payload."
But pre-agent-steps fetches that same body into ./source/RELEASE_NOTES.md in this public repo, the agent reads it, and then:
docs-sync.lock.yml:1108-1123uploads/tmp/gh-aw/agent-stdio.logand/tmp/gh-aw/agent_output.jsonas artifactagent— publicly downloadable on a public repo;parse_claude_log.cjsrenders the transcript into the run's step summary — publicly readable.
The v0.86.2 redaction fix cited at line 45 redacts secrets.* values, not private-infrastructure prose. The only thing left standing between the DEPLOYMENT CHECKLIST section and a public URL is the prompt text at .github/prompts/docs-sync.md:185 ("Internal — never publish, never quote") plus the agent's own PR body, which is also public.
In other words: the structural control was applied to the payload and replaced with a prompt-level control everywhere else. If the private-infrastructure exposure is the real concern, this needs a structural answer too — strip the checklist section before the agent sees it, or disable the transcript artifact/step-summary upload on this workflow.
There was a problem hiding this comment.
Fixed structurally in fc55a75: the fetch step now strips the internal sections (DEPLOYMENT CHECKLIST, STATISTICS, TESTING, DEPENDENCIES, per desci-infra/.github/prompts/release-notes.md) before the agent ever sees the file, so they can never enter the transcript, the public agent artifact, or the step summary. The raw body only touches RUNNER_TEMP — which, unlike /tmp, is not mounted into the agent container — and is deleted immediately after. The knowledge base's never-publish rule stays as defence in depth, now with an added instruction to treat a surviving internal section as a stripping regression and report it. The contract doc on the spoke PR (desci-infra#675, 6eb3fdf5) records the design.
| -e '^graphql/schemas/' \ | ||
| -e '^prisma/schema\.prisma$' \ | ||
| -e '^docs/service-auth\.md$' \ | ||
| -e '^lambda/(appsync-resolver-labs-lambda|appsync-resolver-evm-tokenization|appsync-resolver-lit-service|desci-hubs-auth-lambda|x402-gateway-lambda|kamu-client-lambda|did-linking-worker|labnft-metadata-lambda|ocl-processor)/' \ |
There was a problem hiding this comment.
Two of the nine lambda directories in this alternation do not exist in desci-infra.
find/rg over desci-infra@HEAD returns nothing for:
lambda/appsync-resolver-lit-service/lambda/desci-hubs-auth-lambda/
and the knowledge-base map cites a third that also does not exist, lambda/appsync-resolver-ipnft-minting/ (.github/prompts/docs-sync.md:55,58,63).
The real directories are lambda/appsync-authorizer-lambda/, lambda/desci-api-lambda/, lambda/desci-ipnfts-processor/, lambda/ipnft-events-lambda/.
Consequence: a release that changes the AppSync authorizer or the Lit/encryption resolver matches no gate pattern, the gate exits 1, and no run happens. api-reference/authentication.md, api-reference/labs-api/service-tokens.md and technical-deep-dive/data/data-privacy-and-access.md can then only ever update as ride-alongs of an unrelated schema change, and the map rows pointing at them are permanently dead.
The failure is invisible — it looks exactly like "release touched no documented surface".
There was a problem hiding this comment.
Fixed in fc55a75, with the real locations verified against the desci-infra code: auth is lambda/appsync-authorizer-lambda/, the service-token resolvers live in lambda/appsync-resolver-labs-lambda/services/token-manager-service.ts, encryption is lambda/common/services/kms-service.ts (KMS envelope — no Lit service exists anywhere in the repo), and the legacy IPNFT resolvers are lambda/desci-api-lambda/. Both the gate alternation and the knowledge-base map rows (authentication, files, service-tokens, ipnft-deprecated, data-privacy-and-access, and the ride-along note) now point at directories that exist.
| -e '^docs/service-auth\.md$' \ | ||
| -e '^lambda/(appsync-resolver-labs-lambda|appsync-resolver-evm-tokenization|appsync-resolver-lit-service|desci-hubs-auth-lambda|x402-gateway-lambda|kamu-client-lambda|did-linking-worker|labnft-metadata-lambda|ocl-processor)/' \ | ||
| -e '^lib/(shared-api-stack|evm-tokenization-service-stack|encryption-stack)\.ts$' \ | ||
| | grep -v -e '^graphql/autogen/' -e '^prisma/generated/' || true) |
There was a problem hiding this comment.
This exclusion is dead code, and it misses the one generated file that is actually in scope.
The include set is ^graphql/schemas/, ^prisma/schema\.prisma$, ^docs/service-auth\.md$, the lambda alternation, and the three lib/*.ts files. No string can match ^graphql/schemas/ and ^graphql/autogen/ simultaneously, and ^prisma/schema\.prisma$ excludes prisma/generated/ by construction — so grep -v -e '^graphql/autogen/' -e '^prisma/generated/' provably never removes a line.
Meanwhile graphql/schemas/merged-schema.graphql is a committed build artifact — the knowledge base says so itself (.github/prompts/docs-sync.md:125: "assembled at build time from the other schema files") — and it matches ^graphql/schemas/. Any release where codegen regenerated the merged schema without a hand-authored schema change starts a full agent run against max-ai-credits: 600, which is precisely what the gate comment ("must never trigger a docs run") claims to prevent.
Fix: target merged-schema.graphql here; the two patterns it currently carries can be deleted.
There was a problem hiding this comment.
Fixed in fc55a75: the two provably-dead patterns are gone, replaced by the exclusion that matters — ^graphql/schemas/merged-schema\.graphql$ — with a comment explaining why (committed build output inside an include path; a codegen-only refresh must not buy an agent run).
| - path: . | ||
| current: true | ||
| - repository: moleculeprotocol/desci-infra | ||
| ref: ${{ github.event.client_payload.sha }} |
There was a problem hiding this comment.
client_payload fields are interpolated into action with: inputs, against this PR's own rule.
docs-sync-dispatch-contract.md:443-444, authored in the same PR, states: "Bind every client_payload field via env: and write it to a file. Never interpolate one into a run: block, a prompt template, or an action's with:."
Three places do exactly that: this ref:, and repositories: at lines 53 and 170 — propagated by the compiler to docs-sync.lock.yml:475, 1707, 1863. The run: blocks correctly use env:, so the discipline was applied unevenly.
Practical exposure is bounded (sending a dispatch requires contents: write on this repo), but the rule was written down as an invariant in this PR and broken in this PR, which means the next reviewer cannot rely on it. Either bind these through env: too, or amend the contract to state the with: exception and why it is safe.
There was a problem hiding this comment.
Fixed in fc55a75 + the spoke PR (desci-infra#675, 6eb3fdf5), taking both halves of your proposed fix: the two repositories: sites are now literals, so the only remaining client_payload in a with: is the checkout ref: — which has no env: alternative — and the contract now states that exception explicitly and why it is bounded (structured data to an API call, never shell text; reaching it requires contents: write on desci-infra; the gate rejects any non-pinned source repo). The invariant a reviewer can rely on is restored, just with its edge documented.
| # Pilot expiry (IP-2867). Baked to an absolute UTC timestamp at FIRST compile and | ||
| # then preserved; editing this value alone does nothing. To extend deliberately: | ||
| # gh aw compile --refresh-stop-time | ||
| stop-after: "+14d" |
There was a problem hiding this comment.
The pilot expires 2026-08-28 10:16:52, before the rollout it depends on can plausibly land.
docs-sync.lock.yml:1 records "stop_time":"2026-08-28 10:16:52" and line 1692 bakes it into GH_AW_STOP_TIME. pre_activation.outputs.activated (line 1655) requires stop_time_ok == 'true', and both activation and agent gate on it — so past that instant the run completes green with nothing done and no notice distinguishable from the ordinary skip.
Meanwhile IP-2861 has not landed. The runbook (docs-sync-credentials-runbook.md:174-177, 343-350) requires an org admin to register two GitHub Apps, add three secrets/vars across two repos, merge this hub to main of the public repo first, then merge the spoke to dev. Four steps, two repos, one named-owner requirement — if any of it slips past 2026-08-28 the pilot never fires once, and the failure is silent on both sides (spoke: ::warning; hub: green no-op).
Fix: re-anchor the expiry to the pilot start rather than the compile date, via gh aw compile --refresh-stop-time once the credentials are provisioned. (The PR body already flags this — worth treating as a blocking checklist item rather than a note.)
There was a problem hiding this comment.
Refreshed in fc55a75 via gh aw compile --refresh-stop-time: the baked expiry is now 2026-08-31 19:56:53 UTC, and the PR body's merge-by warning is updated to match. Agreed it stays a blocking checklist item — if DOCS-1 provisioning slips past that date, the stop time needs one more refresh at merge time.
| # beyond the three named) that get documentation updates only when a | ||
| # triggering path changed in the same release. Broaden here | ||
| # deliberately — every addition buys agent runs. | ||
| CHANGED=$(gh api --paginate \ |
There was a problem hiding this comment.
The compare API caps files at 300 entries, so a large release can silently skip documentation.
--paginate does not help here: the compare endpoint returns at most 300 files regardless, and pagination walks commits, not files.
A release like 2.0.0 (Aurora subnet moves, operator scripts, migrations, plus generated artifacts) can easily exceed 300 changed files. Whichever doc-relevant paths fall outside the truncated window are invisible, COUNT can reach 0, and the gate emits ::notice::Release touched no documented surface for a release that did change the GraphQL schema.
The bigger the breaking release, the more likely it is skipped — the inverse of what the gate is for.
Fix: page the commit list and union per-commit files, or fall back to a full-diff path when .files | length == 300.
There was a problem hiding this comment.
Fixed in fc55a75: the compare response is captured to a file, and when .files | length >= 300 (the documented cap) the gate falls back to unioning per-commit file lists — --paginate over the compare's commits[], then --paginate over each commit's files[] (the commit endpoint does paginate files), sort -u. Any failure in the fallback escalates through the new hard-failure path rather than truncating silently.
| client-id: ${{ vars.DOCS_SYNC_APP_CLIENT_ID }} | ||
| private-key: ${{ secrets.DOCS_SYNC_APP_KEY }} | ||
| owner: moleculeprotocol | ||
| repositories: ${{ github.event.client_payload.repo_name }} |
There was a problem hiding this comment.
This mint omits permission-contents: read, unlike its sibling in pre-agent-steps.
The pre-agent-steps mint ends with permission-contents: read (line 171); this one has only client-id / private-key / owner / repositories, so it requests the App's full permission set instead of down-scoping.
Today docs-sync-source-read is provisioned with Contents: Read-only and nothing else (docs-sync-credentials-runbook.md:209), so the two are equivalent — by accident. The moment that App is granted anything further (the runbook's own §1 warns "Permission scope is final" precisely because someone will be tempted), this token silently inherits it while the notes token does not. This is also the token exposed to a run: block that interpolates payload-derived values into a URL.
Fix: add permission-contents: read here so the down-scoping is stated, not inherited.
There was a problem hiding this comment.
Fixed in fc55a75: the gate mint now carries permission-contents: read, and is SHA-pinned to bcd2ba4 # v3.2.0 like its pre-agent-steps sibling (closing the floating-@v3 drift noted in the spoke review). A comment records why the down-scoping is stated rather than inherited.
| @@ -0,0 +1,199 @@ | |||
| # docs-sync — agent knowledge base | |||
There was a problem hiding this comment.
This knowledge base carries every hard guardrail, but is loaded by asking the agent to read a file.
docs-sync.md:150-153 says "Before you do anything else, read .github/prompts/docs-sync.md in this repository", and the compiled prompt (docs-sync.lock.yml:305) contains only {{#runtime-import .github/workflows/docs-sync.md}} — this file is never inlined. Two consequences:
- Not guaranteed to be in context. Everything load-bearing lives here: the never-invent rule, the flag-don't-delete rule ("the single worst failure mode here"), the never-publish-DEPLOYMENT-CHECKLIST rule that is the last remaining control on the release-notes exposure, and the never-edit-
SUMMARY.mdrule thatdocs-sync-write-strategy.md:579-581explicitly notes is "kept safe by instruction ... not by configuration". If the agent skips or truncates that Read — plausible once theBash(cat)denial starts failing its tool calls — it proceeds with none of them. - Outside the lock's hashes. This file is not covered by
body_hash/frontmatter_hash, so it can be edited without recompiling and without the lock-file staleness check firing. The guardrails can drift from the workflow that depends on them with no signal.
Fix: load it with gh-aw's {{#import}}, which inlines it and brings it under the hash.
There was a problem hiding this comment.
Fixed for point 1, honestly not fixable for point 2 at this gh-aw version — fc55a75:
- The knowledge base is now injected via
{{#runtime-import .github/prompts/docs-sync.md}}, so it is guaranteed to be in the agent's context on every run; nothing depends on the agent choosing (or managing) to read a file. ({{#import}}is deprecated in v0.86.2 and does not statically inline — the compiler recommends runtime-import for content injection.) - Hash coverage is not achievable: v0.86.2 inlines nothing — even the workflow's own body reaches the prompt via
{{#runtime-import}}— and I verified empirically that an edit to the knowledge base changes zero bytes of the lock. The residual risk (KB edits take effect without a recompile) is now documented in an inline comment at the import site, flagging that changes to.github/prompts/docs-sync.mdneed workflow-grade review. A CI freshness/review guard is the remaining option if we want this mechanical.
…ate failures, structural notes stripping Review follow-up on #18 (0xmme). The two blockers: - bash allow-list entries now carry :* so they compile to prefix rules instead of exact matches (Bash(git diff) matched only the literal string "git diff"), and "git -C:*" is added so `git -C source ...` — the pipeline's primary signal — is actually permitted. The tools list, the workflow body and the knowledge base all agree on the `git -C source` form. Relevance gate: - Distinguishes "nothing doc-relevant" (quiet green skip, plain exit 1) from "could not diff at all" (hard_failure output + a follow-up step with no continue-on-error that fails pre_activation red): bad credentials or API errors no longer masquerade as no-op releases. - Rejects dispatches from any repo other than moleculeprotocol/desci-infra up front; both App mints and the checkout are now consistently pinned to the literal source repo (the pilot is single-source by design), with permission-contents: read stated on both mints and the gate mint SHA-pinned like its sibling. - Falls back to a per-commit file-list union when compare returns 300 files (the API cap --paginate cannot lift), so the biggest releases are no longer the ones most likely to be silently skipped. - Trigger paths point at directories that exist: appsync-authorizer-lambda and lambda/common/services/kms-service.ts replace the nonexistent appsync-resolver-lit-service / desci-hubs-auth-lambda; the dead autogen/generated exclusion is replaced by the one that matters, graphql/schemas/merged-schema.graphql (committed build output). Release-notes exposure: - The fetch step strips the internal sections (DEPLOYMENT CHECKLIST, STATISTICS, TESTING, DEPENDENCIES) before the agent sees the file, because everything the agent reads lands in a publicly downloadable transcript artifact and the step summary. The raw body only touches RUNNER_TEMP (not mounted into the agent container) and is deleted. The knowledge base's never-publish rule stays as defence in depth. Knowledge base: - Injected into the prompt via {{#runtime-import}} instead of asking the agent to read the file, so the guardrails are guaranteed to be in context. v0.86.2 has no static inlining, so the file remains outside the lock hashes — documented inline as a review caveat. - Map rows corrected to real desci-infra paths (token-manager-service, appsync-authorizer-lambda, kms-service, desci-api-lambda as the legacy IPNFT resolver). Recompiled with gh aw v0.86.2 --refresh-stop-time: pilot expiry moved from 2026-08-28 10:16:52 to 2026-08-31 19:56:53 UTC. compile --validate --strict and lint are clean; recompile produces zero drift. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

The docs-repo half of the docs-sync pipeline from the
IP-2745 spike. Implements DOCS-4
(IP-2864), DOCS-3
(IP-2863) and DOCS-6
(IP-2866).
The spoke half lives in moleculeprotocol/desci-infra#675.
What lands
.github/workflows/docs-sync.md+.lock.yml.github/prompts/docs-sync.mdrelease-notes/**+SUMMARY.md.github/aw/actions-lock.json,.gitattributesgh aw compile. Pins every action to a SHA.This is inert on merge. Nothing runs until the credentials in
IP-2861 exist. Until then a dispatch is accepted with
204and does nothing.gh-aw pin: v0.86.2
Pinned and documented in the workflow header (with the reason — an IP-2864 acceptance criterion).
History: v0.85.4 was chosen 2026-08-07 to stay clear of the 0.68.4–0.71.3 billing bug; bumped to
v0.86.2 on 2026-08-14 for the Claude-harness retry fix
(github/gh-aw#51793 — at v0.85.4 a
permission_deniedon a compound bash command could burn the whole retry budget, and this workflow's
engine: claude+ strict bash allow-list is exactly that scenario) and for enforced secretredaction in step summaries and patch artifacts (#50777/#50778). The bump is behaviour-neutral for
the write strategy: safe-output schemas and the
close_older_pull_requestshandler are unchangedbetween the two versions.
How it works
desci-infrafires arepository_dispatch(docs-sync) on each production release. The hub then:pre_activation, before the agent job exists. One compare API call, noclone. A release touching no documented surface stops here at zero AI credits — which is most
releases. The gate is a deliberate triggering subset of the knowledge-base map; ride-along
surfaces (deprecated IPNFT lambdas, secondary
lib/stacks) update only when a triggering pathchanged in the same release.
desci-infraout at the released SHA into./source../source/RELEASE_NOTES.mdwith the same read-only App(
pre-agent-steps, on the runner, outside the agent container). The dispatch payload no longercarries the notes: it lands in a public repo's workflow run, and source release bodies now carry
real content naming private infrastructure (desci-infra 2.0.0, 2026-08-12). The Releases API is
covered by
contents: read, so the App's permission set is unchanged.Security review
Required by
gh aw compile's safe-update mode, which flagged two new restricted secrets.New secrets
ANTHROPIC_API_KEY— the engine key. The same org keydesci-infra,desci-ecosystemandLabsalready use forclaude-review.ymlandclaude-release-notes.yml. gh-aw's Claude enginerequires an API key as an Actions secret; provider OAuth is unsupported.
DOCS_SYNC_APP_KEY(+ variableDOCS_SYNC_APP_CLIENT_ID) — private key of a GitHub App holdingcontents: readondesci-infraand nothing else. It mints a ~1h token scoped per run to asingle repo. It cannot write anywhere, and it is not installed on this repo.
Neither grants write access to any source repository. The only cross-repo write credential in
the whole design is the dispatch credential, and it lives in
desci-infraand points at thisrepo — which is the property the hub-and-spoke shape exists to preserve.
Verified in the generated
.lock.ymlafter the v0.86.2 recompile, not assumed:agentjob haspermissions: {contents: read}. Every write happens in the separatesafe_outputs/conclusionjobs. gh-aw's strict mode rejects a write permission on the agent jobat compile time.
--exclude-env) and registered for logredaction. The release-body fetch runs on the runner, outside the container — its token never
enters the agent's environment.
defaultsallow-list.actions/checkout@3d3c42e…v7.0.1,actions/create-github-app-token@bcd2ba4…v3.2.0,gh-aw-actions/setup@6aab9e5…v0.86.2).Prompt-injection posture. The release body originates in a pull-request description and is
untrusted. The knowledge base instructs the agent to treat
./source/RELEASE_NOTES.mdas data tosummarise, never as instructions, and to report anything that looks like directions addressed to it.
Structurally the agent is read-only and cannot push — it can only emit a PR through the sanitised
safe-output job.
DOCS-2: the write strategy, and why it isn't a rolling PR
IP-2745 wanted the agent to append to one long-lived sync PR. Verified against the shipped handler
source at v0.85.4 (and re-checked at v0.86.2):
create-pull-requesthas onepulls.createcall and zeropulls.update/pulls.listcalls,and no
branch:key. It can never update an existing PR.push-to-pull-request-branchcannot bootstrap a PR, and underrepository_dispatchits defaulttarget: "triggering"hard-fails. A rolling PR is technically constructible viatarget: "*"(the agent supplies the PR number itself) — the decision record explains why werejected that shape: more prompt surface, PR-list access for the agent, and a wider push blast
radius than "create a fresh draft".
update-pull-requestcannot create.So: new PR per run +
close-older-pull-requests: true, which delivers the property actuallywanted — at most one open docs PR at a time — with no force-push or lost-review-history hazards.
Quick-succession dispatches serialize under the compiled workflow's concurrency group, so two runs
never race. Full rationale in
desci-infra/docs/docs-sync-write-strategy.md.The trade-off, stated plainly: an unreviewed docs PR is closed, not merged, when the next
release lands. The relevance gate keeps the real rate well below the release rate, and closed
branches are not deleted, so nothing is unrecoverable.
Pilot expiry is built in
stop-after: +14dis baked to an absolute 2026-08-31 19:56:53 UTC (refreshed 2026-08-17 whileapplying the review findings), after which the trigger auto-disables. A plain recompile preserves the baked time —
it can only be extended deliberately, via
gh aw compile --refresh-stop-time.arrives already expired.
Release notes (DOCS-6)
New
release-notes/section, one page per API area, newest-first. It complementsapi-reference/changelog.mdrather than duplicating it: this section answers "what shipped in1.0.14", that page answers "how do I migrate off
ipnftUid". Cross-linked both ways.The section ships unseeded, on purpose (commit aecc762): the entry-format template in
release-notes/README.mdis the whole specification, and a hand-written worked example was the onlycontent in this PR asserting facts to external integrators. The first real entry comes from a pilot
run, which also exercises the pipeline properly. (Linear note: IP-2866's "seeded with real past
releases" AC should be amended to record this deviation.)
Known debt tracked separately: 43 stale
isSuccessreferences across 9 reference pages from the1.0.14 error-contract cutover.
Verification
gh aw compile(v0.86.2),gh aw validate --strictandgh aw lintall exit clean, and arecompile produces zero drift against the committed lock. All
SUMMARY.mdand inter-page linksresolve.
Not verifiable until DOCS-1 lands: an end-to-end run.
gh aw trialcannot help — it requiresworkflow_dispatch, which this workflow does not have — so the first real exercise is the runbook'smanual dispatch test.
Two things to know before merging
repository_dispatchonly triggers workflows on the default branch. This must be onmainbefore any dispatch does anything; it cannot be tested from this branch.
bots:allow-list must match the App slug exactly.repository_dispatchis not a gh-aw"safe event", so the pre-activation membership check runs against
github.actor. Ifmolecule-docs-sync[bot]does not match the App created in DOCS-1, every run is rejectedsilently. Confirm the literal actor string from the first run's
pre_activationlog.🤖 Generated with Claude Code