Context
#17148 established the principle for this pipeline: an optional DevIndex intake failure must not decide whether the corpus publishes. It fixed that at the stage level — the four intake stages now defer instead of aborting emission.
The same defect exists one layer up, at the step level, and it is currently the larger exposure. Found while verifying the #17150 credential work, not from a live outage — filing before it bites rather than after.
The Problem
.github/workflows/data-sync-pipeline.yml runs, in order:
| line |
step |
| 69 |
Mint Publisher installation token |
| 83 |
Mint Intake installation token |
| 125 |
Checkout repository |
| 164 |
Setup Node.js |
| 176 |
Run bounded Data Sync emission and publish |
| 190 |
Push Data to neomjs/pages |
The intake mint at :83 has no continue-on-error and no if:, so any failure there fails the job — before checkout, before Node, before a single corpus byte is generated. The corpus then freezes for a reason that has nothing to do with the corpus: the DevIndex intake identity is an enrichment credential, and the corpus is emitted under the reader identity, which the intake mint has no bearing on.
actions/create-github-app-token fails the step when the credential cannot serve the request. Measured both ways on live probes, and both are real triggers:
| injected fault |
mint result |
run |
| permission not granted to the installation |
HTTP 422 — "The permissions requested are not granted to this installation" |
31877595849 |
| named repository has no installation |
404 Not Found |
31879164402 |
Reading caution, learned the hard way on this ticket. Once continue-on-error is in place, a failed mint reports conclusion: success (only outcome keeps the failure), so it renders green in gh run view --json jobs. I read that field, concluded the mint could not fail, and briefly re-scoped this whole ticket on it — the guard had masked the failures that justify it. Check the mint by reading its log. Caught by @neo-gpt.
A third trigger, a rotated or expired private key, is untestable here. So the realistic triggers are ordinary administration, not exotic faults:
- a permission is revoked on
neomjs-devindex-intake while the workflow still requests it;
DATA_SYNC_INTAKE_PRIVATE_KEY is rotated or expires;
- the installation is removed from one of the DevIndex repos.
This is newly likely. #17150 adds permission-contents: write to that mint because GitHub now infers collaborator status from contents-write, and @tobiu granted the App several permissions during that investigation with an explicit intent to clean up the extras. The workflow line and the App grant are now a coupled pair that must move together — and if they drift apart in the wrong direction, the failure is not "DevIndex opt-in is stale", it is "the whole corpus stops publishing", which is the 19-hour freeze #17131 recorded.
The Architectural Reality
.github/workflows/data-sync-pipeline.yml:83-109 — the intake mint, unguarded, ahead of everything.
.github/workflows/data-sync-pipeline.yml:125 — checkout; nothing before this contributes to the corpus.
buildScripts/dataSyncPipeline.mjs — emissionCommands; the corpus stage runs under tokenScope: 'reader' (the implicit Actions token), and content indexes and SEO likewise. Neither consumes the intake token.
buildScripts/dataSyncPipeline.mjs — emitGeneratedData already models the correct semantics for exactly this: a preflight denial is deferred, every stage still runs, the corpus publishes, and the deferred error is rethrown so the run still exits non-zero (#17148).
The runtime already knows how to degrade. The workflow does not, and it fails earlier.
The Fix
Make an intake mint failure degrade rather than abort, mirroring the semantics #17148 already shipped one layer down:
continue-on-error: true on the intake mint step (the Publisher mint stays fatal — it is the publish credential, and without it there is nothing to fix).
- Pass the intake token through as it does today; when the mint failed,
steps.intake-token.outputs.token is empty, and scopedStageEnv('intake') already produces a credential-less child, so the intake stages fail on their own terms and defer via publishGeneratedProgressOnFailure. No new error path is needed — the existing deferral absorbs it.
- Ensure the run still exits non-zero and names the mint as the cause, so this cannot become a silent channel where the corpus publishes forever while DevIndex intake is quietly dead.
Explicitly rejected — do NOT simply mark the step non-fatal and move on. Without (3), a revoked intake permission would produce indefinitely green runs with a permanently dead intake, which is strictly worse than today's loud failure. The point is to move the failure later, not to remove it.
Contract Ledger Matrix
| Target Surface |
Source of Authority |
Proposed Behavior |
Fallback |
Docs |
Evidence |
| Intake mint step |
.github/workflows/data-sync-pipeline.yml:83 |
tolerate failure; corpus still emits and publishes |
Publisher mint stays fatal |
inline comment |
dispatch with a deliberately invalid intake permission ⇒ corpus commit still produced |
| Run exit status on mint failure |
buildScripts/dataSyncPipeline.mjs deferral path |
non-zero, naming the mint |
unchanged when the mint succeeds |
inline comment |
same dispatch ⇒ run red, cause named |
Decision Record impact
none — extends the stage-isolation property #17148 established to the step layer; introduces no new architectural authority.
Acceptance Criteria
Out of Scope
- The
#17150 permission decision itself.
- The DevIndex opt-in phase isolation (
#17150), which is the same class inside the service rather than in the workflow.
- Any change to the Publisher identity or the two-App split (
#15744).
Avoided Traps
- Assuming the runtime deferral already covers this. It does not:
dataSyncPipeline.mjs never executes, because the job dies three steps before it is invoked. The fix has to be in the workflow.
- Making the step non-fatal without preserving the failure. That trades a loud freeze for a silent dead intake — the carve-out-opens-a-silent-channel shape this pipeline has been bitten by before.
- Treating this as hypothetical. The trigger is a scheduled permission cleanup that is already planned.
Related
#17148 / PR #17149 — the same blast-radius principle at the stage layer; this is its step-layer sibling.
#17150 / PR #17160 — adds the contents coupling that makes this newly reachable.
#17131 — the standing alarm that records what a corpus freeze costs (~19h).
#15744 — the two-identity split whose Publisher/intake asymmetry is the reason only one mint should be fatal.
Retrieval Hint: "intake mint failure aborts data sync before checkout; corpus freeze from an enrichment credential"
Context
#17148established the principle for this pipeline: an optional DevIndex intake failure must not decide whether the corpus publishes. It fixed that at the stage level — the four intake stages now defer instead of aborting emission.The same defect exists one layer up, at the step level, and it is currently the larger exposure. Found while verifying the
#17150credential work, not from a live outage — filing before it bites rather than after.The Problem
.github/workflows/data-sync-pipeline.ymlruns, in order:The intake mint at :83 has no
continue-on-errorand noif:, so any failure there fails the job — before checkout, before Node, before a single corpus byte is generated. The corpus then freezes for a reason that has nothing to do with the corpus: the DevIndex intake identity is an enrichment credential, and the corpus is emitted under thereaderidentity, which the intake mint has no bearing on.actions/create-github-app-tokenfails the step when the credential cannot serve the request. Measured both ways on live probes, and both are real triggers:A third trigger, a rotated or expired private key, is untestable here. So the realistic triggers are ordinary administration, not exotic faults:
neomjs-devindex-intakewhile the workflow still requests it;DATA_SYNC_INTAKE_PRIVATE_KEYis rotated or expires;This is newly likely.
#17150addspermission-contents: writeto that mint because GitHub now infers collaborator status from contents-write, and @tobiu granted the App several permissions during that investigation with an explicit intent to clean up the extras. The workflow line and the App grant are now a coupled pair that must move together — and if they drift apart in the wrong direction, the failure is not "DevIndex opt-in is stale", it is "the whole corpus stops publishing", which is the 19-hour freeze#17131recorded.The Architectural Reality
.github/workflows/data-sync-pipeline.yml:83-109— the intake mint, unguarded, ahead of everything..github/workflows/data-sync-pipeline.yml:125— checkout; nothing before this contributes to the corpus.buildScripts/dataSyncPipeline.mjs—emissionCommands; the corpus stage runs undertokenScope: 'reader'(the implicit Actions token), andcontent indexes and SEOlikewise. Neither consumes the intake token.buildScripts/dataSyncPipeline.mjs—emitGeneratedDataalready models the correct semantics for exactly this: a preflight denial is deferred, every stage still runs, the corpus publishes, and the deferred error is rethrown so the run still exits non-zero (#17148).The runtime already knows how to degrade. The workflow does not, and it fails earlier.
The Fix
Make an intake mint failure degrade rather than abort, mirroring the semantics
#17148already shipped one layer down:continue-on-error: trueon the intake mint step (the Publisher mint stays fatal — it is the publish credential, and without it there is nothing to fix).steps.intake-token.outputs.tokenis empty, andscopedStageEnv('intake')already produces a credential-less child, so the intake stages fail on their own terms and defer viapublishGeneratedProgressOnFailure. No new error path is needed — the existing deferral absorbs it.Explicitly rejected — do NOT simply mark the step non-fatal and move on. Without (3), a revoked intake permission would produce indefinitely green runs with a permanently dead intake, which is strictly worse than today's loud failure. The point is to move the failure later, not to remove it.
Contract Ledger Matrix
.github/workflows/data-sync-pipeline.yml:83buildScripts/dataSyncPipeline.mjsdeferral pathDecision Record impact
none— extends the stage-isolation property#17148established to the step layer; introduces no new architectural authority.Acceptance Criteria
workflow_dispatchwith a deliberately unsatisfiable intake permission, which fails the mint for real rather than simulating it.#17150(workflow line and App grant move together) is updated to reflect that drift now degrades rather than freezes.Out of Scope
#17150permission decision itself.#17150), which is the same class inside the service rather than in the workflow.#15744).Avoided Traps
dataSyncPipeline.mjsnever executes, because the job dies three steps before it is invoked. The fix has to be in the workflow.Related
#17148/ PR #17149 — the same blast-radius principle at the stage layer; this is its step-layer sibling.#17150/ PR #17160 — adds thecontentscoupling that makes this newly reachable.#17131— the standing alarm that records what a corpus freeze costs (~19h).#15744— the two-identity split whose Publisher/intake asymmetry is the reason only one mint should be fatal.Retrieval Hint: "intake mint failure aborts data sync before checkout; corpus freeze from an enrichment credential"