Simulators
GitHub changelog
What changed in each GitHub simulator release, newest first.
v0.0.16 — 2026-11-06
-
The page says where the simulator departs from GitHub. A Differs from GitHub line beside a behavior, or an entry under Limitations, marks each departure. “Unconfirmed” marks behavior not yet checked against GitHub.
-
The GitHub page reads plainly. Its Auth, Seeding state and Webhooks sections set out the caller’s permissions, the record keys and the event each change sends as tables, in short sentences.
-
The page has an Errors and limits section. It shows each REST error body and 422 form, the GraphQL error fields and that the simulator never throttles on its own. It says how
per_page,pageand theLinkheader page a list, names the lists that page differently and says what a repeated merge or label write returns.
v0.0.15 — 2026-11-06
- The page states what only this simulator has. Shared connection steps live on the installation page.
v0.0.14 — 2026-11-06
-
A push re-settles
mergeable_statefor the new head. A push that moves an open pull request’s head now re-settles itsmergeable_statefrom the new head’s results, as GitHub recomputes it for the new commit: anunstablepull request readscleanonce its author pushes a head that carries no results yet, andunstableagain when an unfinished or failing result lands on it. A head that still conflicts with its base staysdirty, and thesynchronizeevent carries the re-settled verdict. -
GET /orgs/{org}answers the organization. A scenario’s organization reads back in GitHub’s organization shape, its login matched in any case, and a user’s login or an unknown name answers GitHub’s404 Not Foundenvelope. terraform-provider-github reads it at configure time to tell an organization owner from a user. -
Auto-merge and the merge queue.
gh pr merge --auto(enablePullRequestAutoMerge) records an auto-merge request on a blocked pull request (auto_mergeover REST,autoMergeRequestover GraphQL, theauto_merge_enabledevent and webhook), and the twin merges it on the enabler’s behalf with the recorded method as soon as the base’s protection is met;--disable-autoclears it, a no-op with nothing to clear and refusedCan't disable auto-merge for this pull request.on a closed one, and a landed request stays on the merged record as GitHub keeps it. A repository must allow auto-merge unless the base holds a merge queue, and off a queue base a pull request nothing blocks is refusedPull request is in clean status, as GitHub answers. A merge queue is a seededstate_merge_queuerow on a branch:enqueuePullRequest, or an auto-merge request on such a base, adds a pull request whose protection is met as an entry whose merge group is built as the branchgh-readonly-queue/<base>/pr-<number>-<sha>, starts the repository’smerge_groupworkflows on it and sends themerge_groupwebhook; entries land in order, each by fast-forwarding the base to its group, once the group’s required contexts pass; a pull request that moves, closes, retargets or turns draft leaves the queue, a base moved from outside it rebuilds the groups,dequeuePullRequestremoves one and rebuilds the groups behind it, andRepository.mergeQueuelists them. GitHub holds a queue only on an organization’s repository, so the queue’s answers beyond the refusals a base without one draws are the twin’s own, pinned by its tests. -
Repository rulesets gate merges.
POST /repos/{owner}/{repo}/rulesetscreates a ruleset;GETlists them,GET,PUTandDELETE .../rulesets/{ruleset_id}read, update and delete one, andGET .../rules/branches/{branch}lists the rules that apply to a branch. An active ruleset’s required status checks and approving review count block the pull requests into the branches its conditions name and refuse their merge with GitHub’sRepository rule violations foundanswer, listing every violation, until they are met; a repository admin merges past one only as a bypass actor, and a branch a ruleset applies to readsprotected. -
No
github-pulls_garrytan_gbraincomment is dated after the scenario’s clock. Two Trunk bot comments, on #5194 and #5294, carried anupdated_atand a body from after2026-09-23T00:00:00Z; they are left out, and each pull request’scommentscount follows (393 comments, from 395). -
The first calls of gh, octokit (Ruby), @octokit/rest and terraform-provider-github are measured on every round. The requests each client makes before anything else are now tests compared against real GitHub in every full round:
GET /user,GET /, the GraphQL viewer read gh sends with its preview and API-version headers, a repository and its pulls, and terraform’s organization check. -
Write files and refs without a clone. The contents API (get, create or update, and delete a file) and the git data API (create, update and delete refs, list matching refs, create and get blobs, trees and commits) are served, and every write lands in the repository as a push does:
git fetchfinds it, an open pull request on the branch synchronizes, and thepushwebhook and push-triggered workflows follow. Deleting an open pull request’s head branch, bygit push --deleteor through the API, closes the pull request and logshead_ref_deletedandclosed, and deleting a branch open pull requests are based on closes them or retargets them onto a merged head’s base. -
GraphQL reads branch protection. A ref’s
branchProtectionRuleand a repository’sbranchProtectionRulesanswer the protection set throughPUT .../branches/{branch}/protection: its pattern, required status checks and approving reviews, admin enforcement and its other switches, as GitHub answers them. A status-check rollup context’sisRequired(pullRequestId:)orisRequired(pullRequestNumber:)is true when that pull request’s base requires a context of exactly its name, sogh pr checks --requiredlists the checks a protected base requires; with neither argument, or one naming no pull request, it is refused with GitHub’s error. -
A push to a protected branch follows its protection rules. A
git pushto a branch protected throughPUT .../protectionis now judged by that protection’s required status checks, required reviews, force-push and deletion rules: a commit with no passing result for a required check, any direct push while pull request reviews are required, a force push and a delete are refused with GitHub’sremote: error: GH006lines andprotected branch hook declined. A repository admin’s push goes through unlessenforce_adminsis on; a force push or delete is refused to everyone unless the protection allows it. A scenario’s seededprotectedflag, with no protection record behind it, still refuses every push. -
Update-branch, reopen and base changes re-settle
mergeable_state. Updating a pull request’s branch, reopening it and changing its base now re-settle itsmergeable_statefor the head and base it lands on, as a push does: the merge commit update-branch writes carries no results yet, so anunstablepull request readsclean, orblockedwhile its base requires a check the new commit lacks, and itssynchronizeevent carries that verdict; a pull request reopened after a push while closed reads the new head’s verdict, whether it is reopened through the REST pulls or issues route orreopenPullRequest; and a base change readsblockedonto a base whose protection requires a context the head lacks, and releases ablockedverdict when it moves to another base whose protection the head meets, or that has none. -
A push of a tree git’s fsck refuses is refused. A
git pushwhose pack carries a tree holding a.gitentry in any spelling, a symlinked or non-blob.gitmodules, or a non-blob.gitattributesis refused whole, as GitHub refuses it: the report readsunpack index-pack failed, every ref command in the push answersfailed, deletes included, and no ref moves. A corrupt pack answers the same report, where it used to name the read error. -
Pull request workflows see the pull request. A
pull_requestworkflow’sgithub.eventnow carriespull_request, the pull request as its webhook sends it, besidenumber, so a job’sif:and its steps readgithub.event.pull_request.number, its head and its base as they do on GitHub. -
A matrix built from a dependency’s output runs. A job whose
strategy.matrixreads a dependency’s outputs, such as${{ fromJSON(needs.plan.outputs.legs) }}, now expands once that dependency finishes, into one job and check run per combination named as GitHub names them, and the jobs that need it read the combinations’ result together, where before it showed oneneutraljob and its dependents went unevaluated. -
A state dump seeds back as it was, and the next record gets a fresh id.
GET /_rystic/inspectposted toPOST /_rystic/seedafter a reset restores every record where it was, per-caller records included, which used to land under a doubled key. The next record created after any seed passes every uuid and event id the simulator minted that the seed holds.GET /_rystic/inspect?view=sequenceslists every id counter, and a world document’s newsequenceskey restores them, so ids the simulator renders from a counter continue where the dump left off. -
A branch read shows the tip commit of a scenario loaded from a git pack.
GET /repos/{owner}/{repo}/branches/{branch}names the tip commit’s tree, message, author and parents in a scenario whose history comes from a git pack, where it answered them blank. -
A ruleset’s merge queue rule holds a queue. A branch of an organization’s repository that an active ruleset’s
merge_queuerule targets holds a merge queue configured by the rule’s parameters, soenqueuePullRequest,dequeuePullRequest,gh pr merge --autoandRepository.mergeQueuework on it as on GitHub, and the queue’s answers are now measured against GitHub’s own. A ruleset that pairs amerge_queuerule with a wildcard branch pattern is refused, as GitHub refuses it. -
The merge queue answers as GitHub does.
enqueuePullRequestanswers the queue as the entry joined it: the entryQUEUEDwith noheadCommitorbaseCommit, its pull request open. From the next requestRepository.mergeQueuereads the entryAWAITING_CHECKSwith its group built, and an entry whose group conflicts leaves the queue then. The group commit is authored by the enqueuer and committed by GitHub. A landing readsmerged_bythe enqueuer, and the issue’s events gainremoved_from_merge_queuebygithub-merge-queue[bot]andmergedandclosedby the enqueuer. A queue’scheckResponseTimeoutandminimumEntriesToMergeWaitTimeread in seconds. An enqueue is refused in GitHub’s words:Pull request Required status check "<context>" is expected.,Pull request is already in the queue,Pull request is in draft and Pull request not in mergeable state, andNo merge queue found for branch '<base>'on an organization’s repository. -
A fork’s branch moves the upstream pull request in the upstream. A push, a ref update or a contents write to a branch of a fork the scenario holds now moves the upstream pull request whose head it is in the upstream repository, as GitHub does: the upstream’s
/pulls/{n}/fileslists the changed files; the upstream servesrefs/pull/{n}/headat the new commit to a fetch; the test merge is built against the upstream’s base branch, so a conflict with a later upstream change readsdirty; and thesynchronizeevent names the upstream, so a webhook on the upstream receives it. Deleting the fork’s branch closes the pull request with itshead_ref_deletedandclosedevents in the upstream’s timeline and aclosedevent naming the upstream, and a pull request of the upstream whose head is the upstream’s own branch of the same name is left alone. A pull request a scenario seeds without abase.repois read in the repository its key names, and reopening, retargeting or updating one stored under any key form keeps a single record. -
A pull request’s files are its changes since it branched. In a scenario with a repository, a pull request’s
files,changed_files,additionsanddeletionsare now GitHub’s three-dot diff, its head against the merge base with its base branch, wherever it is opened, pushed, updated or retargeted: a commit only the base branch holds no longer reads as a file the pull request removes. A signed commit committed as GitHub itself (GitHub <noreply@github.com>) now readsverified: truewith reasonvalidon every REST route that shows it, and any other signed commit readsunknown_key. On the REST branch, commit and compare reads, a commit’s author and committer are credited by their email; a GitHub noreply address credits only the login it names, and any other ident is credited by its name, then by the login its address’s local part names; and GitHub’s own ident credits itsweb-flowaccount when the scenario seeds one. -
Creating a blob accepts every encoding, as GitHub does.
POST /repos/{owner}/{repo}/git/blobsno longer refuses anencodingother thanutf-8orbase64. Only the exact valuebase64decodescontent, and it decodes leniently: characters outside the base64 alphabet are skipped, a=in the third or fourth place of a group of four ends the read, and nothing is refused. Any other value, or none, stores the text as its UTF-8 bytes. A missing ornullcontent, an empty body, and a body that is a JSON string, number, boolean ornullanswer 422Validation Failedwith amissing_fielderror; acontentthat is not a string answers 422content must be a string; and a body that is not valid JSON answers 400Problems parsing JSON. -
A completed check run is announced. A check run that completes now reaches
check_runhooks and the egress log asaction: completed, as GitHub sends it: throughPATCH .../check-runs/{check_run_id}when it completes the run or changes a completed run’s conclusion, through a create that supplies a conclusion (aftercreated) and for each Actions job as it finishes. -
Git writes to a repository that does not exist answer 404.
POST /repos/{owner}/{repo}/git/refs,PATCHandDELETE /repos/{owner}/{repo}/git/refs/{ref}on a repository the scenario does not hold now answer 404Not Found, as GitHub does, where they answered 409Git Repository is empty.or 422, andDELETE /repos/{owner}/{repo}/contents/{path}answers 404 there whatever its body holds. Creating or updating a ref checks its body first, as GitHub does: a missingreforsha, a value of the wrong type, ashathat is not 40 or 64 lowercase hex characters and a body that is not a JSON object answer 422 with GitHub’s message for each, and on all three ref routes a body that does not parse as JSON answers 400Problems parsing JSONbefore anything else. -
A push and a REST write answer every branch protection rule. A
git push, a ref write through.../git/refsand a contents write to a branch protected throughPUT .../protectionare now judged by all of its rules, as GitHub judges them:lock_branchrefuses every change (Cannot change this locked branch),required_linear_historyrefuses a push that brings a merge commit (naming the commit) andrestrictionsrefuses anyone its users do not list. A user listed in the pull request rule’sbypass_pull_request_allowancespushes past that rule. A refused push prints GitHub’s current layout,error: GH006: Protected branch update failed for refs/heads/<branch>.and then one- <reason>per broken rule, and a repository admin’s push past a rule prints GitHub’sBypassed rule violationsnotice. Creating a protected branch again after it was deleted is judged by no rule, and a push naming a stale commit for a protected branch is declined by its protection first, both as on GitHub. A ref write or contents write refused by protection answers GitHub’s message instead of a generic one, 422 for a ref write and 409 withCould not create file:,Could not update file:orCould not delete file:for a contents write, and an admin’s REST write now passes the rulesenforce_adminslets an admin pass. On a push and onPUT .../merge, a required check still running readsRequired status check "<context>" is pending.rather thanis expected., and several unmet required checks answer by count:2 of 2 required status checks are expected.or2 of 2 required status checks have not succeeded: 1 expected and 1 failing.. -
A ruleset’s every rule refuses a push, and its merge rules refuse a merge. Every rule of a repository ruleset now judges a
git pushto a branch it applies to, as GitHub judges it:deletionrefuses a delete,creationa create,update,pull_requestandnon_fast_forwardan update (a forced one fornon_fast_forward),required_status_checksan update or a create,required_linear_historya push that brings a merge commit andrequired_signaturesone that brings a commit GitHub did not sign, naming the commits. A refused push prints GitHub’serror: GH013: Repository rule violations found for refs/heads/<branch>.with its review link and one- <reason>per broken rule, the branch protection’s unmet rules after the rulesets’, and reportspush declined due to repository rule violations. A repository admin is judged like anyone else, and a bypass actor passes with GitHub’sBypassed rule violationsnotice (always) or silently (exempt). A merge by a method a ruleset’sallowed_merge_methodsleaves out, or that the repository disables, answers405with GitHub’s words (Merge commits are not allowed on this repository.) instead ofPull Request is not mergeable, over REST andmergePullRequest. Apull_requestrule requiring review threads resolved blocks a pull request while a review thread stands on it. -
A merge queue’s group branch takes no push, ref write or contents write. A push, a ref write or a contents write onto the
gh-readonly-queue/<base>/pr-<number>-<sha>branch a merge queue builds for an entry is now refused, from a repository admin too, as GitHub refuses it. Agit pushthat updates, force-pushes or deletes it is declinedprotected branch hook declinedaftererror: GH006: Protected branch update failed for refs/heads/<branch>.and GitHub’s reason,- A pull request for this branch has been added to a merge queue. Branches that are queued for merging cannot be updated. To modify this branch, dequeue the associated pull request., while the push’s other branches land; when a ruleset also refuses the push, it answers GH013 with this reason after the ruleset’s. A ref update or delete through.../git/refsanswers 422 with that message, and a contents write 409 withCould not create file:,Could not update file:orCould not delete file:before it. The group stays where the queue built it, so the entry lands as built. A client can still create and delete other branches undergh-readonly-queue/. -
A push of a commit, tag or tree git’s fsck refuses is refused. A
git pushwhose pack carries a tree holding one name twice or its entries out of git’s order, a submodule entry naming the all-zero id, a commit or annotated tag whose author, committer or tagger line git refuses, or a commit that lacks its tree, author or committer line, is refused whole, as GitHub refuses it: the report readsunpack index-pack failed, every ref command in the push answersfailed, and no ref moves. A malformed timezone and a zero-padded mode still land, as GitHub lets them through. -
A pushed tree with a zero-padded mode clones back. A tree pushed with an entry mode written
040000or0100644, which GitHub accepts, is served back byte for byte under the name it was pushed with, so a fresh clone or fetch of its branch succeeds where it used to fail with a bad-hash error. The API reads such a mode as git does: the tree read and the tree create answer100644, and a commit that only re-spells a mode lists no changed files in its pull request, its push event, its commit read or a compare, so a rebase merge over it keeps the base branch’s changes. Every tree the simulator writes itself is the one git writes. -
A slow client no longer holds up the others. The simulator reads a request’s whole body before it starts serving it and sends the answer after, so a client that uploads slowly, or reads a large answer slowly, no longer delays other clients’ requests. Answers are unchanged; one larger than 4 MiB still makes other requests wait while it is sent.
-
Workflows run on a tree with a symlink or a submodule. A push whose tree holds a symlink or a submodule now gets its workflow jobs run. The job’s checkout carries each symlink as a symlink and leaves a submodule’s directory empty, as
actions/checkoutdoes withoutsubmodules; a symlink whose target leaves the checkout is left out. Such a push used to end every jobneutralwith a snapshot error. -
A pushed commit is checked as GitHub checks it, and clones back. A
git pushwhose pack carries a commit with two author lines, its committer line before its author line, another header between its parents and its author, a parent line between its author and committer, a tree, parent, author or committer keyword with nothing after it where that line belongs, a line before its tree line, or a last header line with no newline is refused whole, as GitHub refuses it, and so is any commit git’s fsck refuses whether or not a blank line follows its headers. A commit GitHub accepts, with a second committer line or a parent line after its committer or no blank line after its headers, lands and is served back byte for byte, so a fresh clone of its branch succeeds where it used to fail with a bad-hash error. -
A push of a
.gitmodulesor.gitattributesgit’s fsck refuses is refused. Agit pushwhose trees name a.gitmodulesholding a submodule url, path or update setting that would run as an option or a command, or a submodule name that climbs out of the repository, a.gitattributeswith a line of 2048 bytes or more, either file naming a tree, or a.gitattributesnaming nothing, is refused whole, as GitHub refuses it: the report readsunpack index-pack failed, every ref command in the push answersfailed, and no ref moves. The blob is read whether the push brought it or the repository already held it under another name. A.gitmodulesor.gitattributesentry is judged by what it names, at any mode but a symlink, so a submodule entry named.gitmoduleslands as GitHub lets it through when the repository does not hold the commit it names, and so do an unparsable.gitmodulesand a 2047-byte attributes line. -
A write beside a pushed subdirectory leaves that subdirectory as pushed. A file created, updated or deleted through the contents API now rewrites only the directories on its own path, and a rebase merge replays each commit’s tree as git cherry-picks it. A subdirectory the change leaves as it was keeps the name it was pushed with, zero-padded modes inside it included, so the new commit’s tree is the one GitHub writes for the same change and a clone of the branch finds every such directory under its pushed name. Squash and regular merges already kept them.
-
The call log shows what each call asked for and whether it succeeded. Each
GET /_rystic/callsentry now carriesargs, the call’s query and body arguments with credentials redacted (long values cut, file contents left out), andok, false for an answer that is not 2xx. A GraphQL answer carryingerrorsat HTTP 200 is not ok either, and its first error’stypeis the entry’serror. A path the simulator does not serve is markedunknown_route: true.?tail=Nreturns only the last N calls, anddroppedcounts the calls the log no longer keeps. -
The docs are one page, with a page per feature. The archive ships
README.mdin place ofHOWTO.mdandSCENARIOS.md, with the same sections in the same order as every Rystic simulator’s page, andCHANGELOG.mdbeside it. Git repositories, GraphQL and theghCLI, search, issues and the timeline, workflows and CI runs, branch protection and rulesets, auto-merge and the merge queue, GitHub Apps, the pull request UI and the review-bot scenarios each have their own page underdocs/features/, and every example on these pages runs as written against a fresh simulator. -
Scenario descriptions use customer terms. The Scenarios table and
--list-scenariosdescribe each scenario in American spelling, with no internal references. -
Placeholders in the docs show on the site. A placeholder such as
<id>in the docs now shows on the site, where it was left out. -
The review-bot scenarios page describes the bot and its test. It now says what
bot.pydoes and which settings it reads, that it needs Python’srequests, howtest_bot.pyfinds a Kalshi simulator, and that approving any broken variant means a review missed the bug. -
The Scenarios table is easier to scan. Each row says in a line what the scenario is for, and a list below the table says what each scenario holds.
-
Pro starts with a 14-day trial. Free has ended, so every simulator is on Pro and Enterprise, and the docs name only those plans.
-
Known clients name their registry. The table at the end of Connect your client names each client’s package registry beside it, such as npm, PyPI or GitHub Releases.
-
Limitations is shorter to read. It opens with the known issues and one sentence counting the rest. The detailed lists follow, and the website folds them behind “Show all limitations”.
-
The docs page links to the boot configuration page for boot inputs. The link used to open a heading the control-plane page now only points away from.
-
The page has no Plans section. The Quickstart names the plans that run the simulator, and the pricing page lists what each plan includes.
-
The page keeps only the simulator’s own facts on reset, seeding and sign-in. The shared pages cover the general mechanics.
-
The feature pages read more plainly, and four topics have pages of their own. Sentences are shorter, and behaviors that differ from GitHub are marked “Differs from GitHub” where they’re described. Rules with several outcomes, such as what settles a pull request’s mergeable state, are tables. How protection and rulesets judge a push is now on Protected pushes. Branching and committing through the API is on Commits through the API. How a workflow job’s result is decided is on CI results and the evaluation engine. Auto-merge has its own page beside Merge queues. Each new page opens with an example that runs as written.
v0.0.13 — 2026-10-29
-
Reactions on issues and issue comments.
POSTandGET .../issues/{issue_number}/reactionsand.../issues/comments/{comment_id}/reactions, andDELETEon each with/{reaction_id}, are served; a repeated reaction answers 200 with the first one, and thereactionsrollup on an issue, an issue comment and a review comment counts them. -
Labels, assignees and milestones. A repository’s labels and milestones can be listed and created and its assignable users listed; an issue’s or pull request’s labels can be listed, added, set and removed and its assignees added and removed; and
POST .../issuesandPATCH .../issues/{issue_number}storelabels,assigneesandmilestoneinstead of dropping them. -
Commits, comparisons, branches and refs.
GET .../commits/{ref}answers a commit with its stats and files (or its diff withAccept: application/vnd.github.diff),GET .../compare/{base}...{head}compares two commits, andGET .../branches,.../branches/{branch}and.../git/ref/{ref}read the repository’s branches and refs. -
Review comments and body-less COMMENT reviews answer as on GitHub. A COMMENT review whose only content is inline comments, and a pending review holding inline comments submitted as COMMENT, no longer need a body. A review comment that names
pull_request_review_idis refused 422 as GitHub refuses it, where it used to open a second review. -
The state UI is included on Free.
/_rystic/uiis available on every plan, including Free and the no-signup trial. The Pro-only verified UI download is a separate feature. -
Setting the clock never stamps records before the timeline events it fired. When
POST /_rystic/clock {"time": ...}fires timeline events due at its target, the next request’s records stamp after theirs instead of reusing their instant; a set past every event still stamps one millisecond past its target, as before. -
A paced clock keeps wall time, and
rystic run --speed 0stops it. UnderRYSTIC_PACE, each tick moves the clock by the wall time that passed since the last, so a tick missed while the twin was busy no longer makes timeline events fire late.rystic runhands the twin only the pace it resolved, so--speed 0, a world without events,--tapeand--livekeep the clock manual even when your shell exportsRYSTIC_PACE. -
Repository and organization webhooks.
/repos/{owner}/{repo}/hooksand/orgs/{org}/hookscreate, list, read, update, ping and delete hooks, with theirconfigroutes. WithRYSTIC_WEBHOOK_DELIVERY=onthe twin POSTs each hook thepush,pull_request,pull_request_review,pull_request_review_commentandcheck_runevents it subscribed to, and apingwhen it is created, signedX-Hub-Signature-256with the hook’s secret as@octokit/webhooksverifies it.rystic gridturns delivery on in every cell and hands each cell’s bot its own$PORT. -
The twin’s own writes log their issue events, and every world’s log agrees with its records. Closing, reopening, renaming, locking, unlocking and merging an issue or pull request, over REST or GraphQL, and readying or drafting a pull request append the event GitHub logs, so
GET .../issues/{n}/eventsand.../timelineshow it with GitHub’s keys. A seeded closed, merged or locked issue or pull request logs the events that put it there, andgithub-pulls_garrytan_gbrainno longer logs a merge on two pull requests it holds open. -
New issue events, reactions and users take IDs above the seeded ones. An event the twin logs, a reaction it creates and an assignee it adds by an unknown login now take the next ID above the largest one the world already holds, rather than starting from 1. An issue’s events list in ID order now lists a new event after the seeded ones, and a new record never overwrites a seeded one whose ID is 1.
-
GET /_rystic/egressshows the twin’s URLs. A webhook event’s URLs on GitHub’s hosts in the egress log are on the origin you read it on, as in the delivered hook;?raw=1returns them on GitHub’s hosts. -
Swagger UI to read and call the twin from the browser.
/_rystic/swagger, linked from the state UI header, is Swagger UI over the published API trimmed to the operations this twin serves; Try it out sends each request to the twin. -
A requested reviewer is found by the login as sent. The seeded reviewer account is now held under the login exactly as a review request sends it, as well as under its lower-case form, so requesting a review from that account finds it whatever case the login is written in.
-
A GraphQL variable of the wrong type or a misused directive is refused before anything runs. A variable whose value its declared type does not hold (
"10"for anInt,5for aString!) now gets an error with graphql-js’sVariable "$v" got invalid value …text, and so does an unknown, misplaced or repeated directive, or one missing a required argument. Before, the query or mutation ran. -
A GraphQL argument written with a value of the wrong type is refused before anything runs. A literal in the query that its argument’s declared type does not hold (an enum value outside the enum,
"5"for anInt,nullfor a required argument, a required input field left out or an unknown one) now gets graphql-ruby’s “Argument ’…’ has an invalid value (…)” error. Before, the query or mutation ran with the value. -
Issue intake sees every metadata change. Adding or removing a label, an assignee or a milestone, through the label and assignee routes or
PATCH .../issues/{issue_number}, logs GitHub’slabeled,unlabeled,assigned,unassigned,milestonedordemilestonedevent with its keys, and an issue created with labels, assignees or a milestone logs them too.GET /repos/{owner}/{repo}/issues/eventsandGET .../issues/events/{event_id}read the repository’s whole event feed. A label and a milestone can each be read, edited and deleted, andDELETE .../issues/{issue_number}/labelsclears an issue’s labels. Issue, issue comment, label and milestone writes now emit theissues,issue_comment,labelandmilestonewebhooks; a label, assignee or lock change on a pull request emitspull_request.PATCH .../issues/{issue_number}takes the deprecated singularassignee. -
Commit statuses. A CI bot that reports through statuses rather than check runs can now post them:
POST /repos/{owner}/{repo}/statuses/{sha}creates a status,GET .../commits/{ref}/statuseslists a ref’s statuses newest first, andGET .../commits/{ref}/statusanswers the combined status — the latest status per context,failureif any context fails,pendingwhile any is pending or none exists. A ref may be a sha or a branch name. Creating a status sends thestatuswebhook, and GraphQL’sstatusCheckRollup, whichgh pr checksreads, lists each context beside the head’s check runs. -
mergeable_statefollows the head’s results. An open pull request readsunstablewhile a check run on its head is unfinished or failed, or the latest commit status of any context is notsuccess, andcleanonce they all pass, as GitHub moves it. A world’sblocked,dirtyorunknownverdict stands. -
Webhook deliveries can be read back and redelivered. Each delivery now carries GitHub’s headers: a GUID
X-GitHub-Delivery,X-GitHub-Hook-Installation-Target-IDand-Type, aGitHub-Hookshot/user agent, and, besideX-Hub-Signature-256, the SHA-1X-Hub-Signature.GET .../hooks/{hook_id}/deliverieslists every attempt sent (a hook’s newest 500), newest first, with cursor paging and astatusfilter.GET .../deliveries/{delivery_id}shows its request and the receiver’s response.POST .../deliveries/{delivery_id}/attemptssends it again with the same payload and GUID, to the hook’s current URL under its current secret. A delivery whose receiver fails is recorded with its status and not retried, as on GitHub, and a repository hook’slast_responsefollows its latest delivery. The same routes serve organization hooks. -
A pull request’s CI evaluates its test merge. A
pull_requestworkflow checks the head merged into the base branch’s current tip, as GitHub’s does:github.shais that merge commit, whichrefs/pull/{n}/mergeserves and an open pull request’smerge_commit_shanames, and the check runs stay on the head. The merge is git’s three-way merge, line by line, so a change that passes alone but breaks once merged with a moved base fails. A head that conflicts with its base starts no workflow, readsmergeable: false,mergeable_state: "dirty", and its merge answers405and update-branch422. Amergeorsquashlands the same merged tree its checks evaluated, and reopening a pull request makes commits pushed to its branch while it was closed its head again. -
Workflow runs, workflow_dispatch and run logs.
/repos/{owner}/{repo}/actions/workflows,.../workflows/{workflow_id}/dispatches,.../actions/runs,.../runs/{run_id}/jobs,.../runs/{run_id}/logsand.../actions/jobs/{job_id}/logsare served, sogh workflow runandgh run view --logwork. A dispatch records a queued run with its inputs; a harness seeds runs, their conclusions and each job’s log text through/_rystic/seed(state_workflow_run,state_workflow_job). -
A GitHub App can authenticate.
GET /app, its installations, the installation lookups by repository, organization and user, andPOST /app/installations/{installation_id}/access_tokensanswer an App’s JWT, which the twin reads by structure (any RSA key signs). The exchange mints aghs_token that acts as the app’s bot user,tb-agent[bot]in every authored world: its writes are the bot’s, its standing follows the installation’s grant, andGET /installation/repositoriesandDELETE /installation/tokenanswer it. An App JWT on any route but the App’s own answers401 Bad credentials, where it used to act as the repository owner. The App routes are not measured against GitHub yet. -
A push’s workflows become Actions runs and jobs. Each workflow a push, a pull request or a
workflow_dispatchstarts is one workflow run in its own check suite, with one job per check run and the evaluator’s output as the job’s log, sogh run list,gh run view --log-failedand GraphQLcheckSuite.workflowRunshow them.POST .../runs/{run_id}/rerun,.../rerun-failed-jobs,.../jobs/{job_id}/rerunand.../runs/{run_id}/cancelare served, with run attempts at.../runs/{run_id}/attempts/{attempt_number}, and each step firesworkflow_run,workflow_jobandcheck_suitewebhooks. -
The GitHub Enterprise Server URL layout. A client pointed at the twin as GitHub Enterprise Server (github-mcp-server with
GITHUB_HOST, octokit withbaseUrl: <host>/api/v3, PR-Agent) reaches every REST route under/api/v3and GraphQL at/api/graphql, each answering exactly as the unprefixed route. Answers under/api/v3carryX-GitHub-Enterprise-Version: 3.17.0. -
HEAD /answers 200. The twin answersHEAD /, andHEAD /api/v3/under the GitHub Enterprise Server layout, as api.github.com answersHEAD /: 200 with theGETanswer’s headers and no body. Renovate callsHEAD <endpoint>/before anything else on a GitHub Enterprise endpoint. -
Reopening a pull request through the issues route picks up its head branch.
PATCH /repos/{owner}/{repo}/issues/{issue_number}with{"state":"open"}on a closed pull request now does whatPATCH .../pulls/{pull_number}does: commits pushed to the head branch while it was closed are its head again andmergeable/mergeable_stateare re-derived, so a head that conflicts with its base readsfalse/"dirty"and its merge and update-branch refuse. Before, the reopen kept the head and verdict it was closed with, and a merge landed that stale head. -
A job whose dependency was skipped is skipped. A skipped job’s outputs read empty, so a job that needs it is gated on its
if:as GitHub gates it: skipped under the implicitsuccess(), and underalways()skipped or run as its condition readsneeds.<id>.result == 'skipped'and the empty outputs. Such a job no longer completesneutral, so a run whose live jobs pass concludessuccess. -
Copilot review fields. Pull request reviews now carry
is_copilot_code_review: falseandcopilot_code_review_effort: null, and review comments carryis_copilot_code_review: falseandcopilot_code_review_sources: [], on every route that returns them, lists included, as GitHub began answering in October 2026. No review in the twin is Copilot’s, so the values never vary. -
Reopening a pull request sends
pull_requestwithaction: reopened. A reopen throughPATCH .../pulls/{pull_number},PATCH .../issues/{issue_number}or thereopenPullRequestmutation now emits the notification GitHub sends for it, so the repository’spull_requestworkflows start on the reopened head and every hook subscribed topull_requestis delivered the event with the reopened pull request, its repository and the caller assender. That head includes the commits pushed to the branch while the pull request was closed. Editing or closing a pull request throughPATCH .../pulls/{pull_number}still sends nothing. -
Branch protection gates merges.
PUT /repos/{owner}/{repo}/branches/{branch}/protectionprotects a branch,GETreads its protection andDELETEremoves it; its required status checks and required pull request reviews have their ownGET,PATCHandDELETEroutes. A pull request into a protected base readsmergeable_state: "blocked"while a required context has no passing result on its head (a commit status, or a check run of that name) or approvals are short, and its merge answers405naming the first unmet requirement, unless a repository admin merges whileenforce_adminsis off. The branch readsprotected: true, and lists underGET .../branches?protected=true, while it holds protection. Rulesets, push rules for API-protected branches and GraphQL’sbranchProtectionRuleare not served yet. -
The docs page lists the clients known to work. A new Clients known to work section names each official client checked against this simulator, the release checked, the ways it connects that work and fail, the day it was checked, and any known gap that covers a failure.
-
A pull request’s file rows have one owner test, and update-branch on a fork head writes nothing to the base repository. The files listing and the re-projection that replaces a pull request’s rows on push, open, update-branch and retarget now decide which stored rows are the pull request’s by the same test, so a row seeded with a null
pull_numberor an upper-case/REPOS/in itscontents_urlcan no longer survive a re-projection and leave the listing longer thanchanged_files. Update-branch on a pull request whose head is a fork’s merges the base into the fork’s head: the merge’s first parent is the pull request’shead.sha, never a same-named branch of the base repository, and norefs/heads/<head.ref>or branch lands in the base repository, as GitHub creates none.
v0.0.12 — 2026-10-12
-
The scripts in this archive are MIT-licensed.
check.shand the archive’s other scripts are published under the MIT License at https://github.com/Rystic-Inc/rystic-scripts , so you can copy, change and share them.LICENSE.txtno longer covers them; it still governs the twin itself. -
bots/test_bot.pyboots your installed Kalshi twin. With neitherKALSHI_BASE_URLnorKALSHI_TWIN_BINset, it runs the kalshi-twin versionrystic pull kalshi-twininstalled, with the API key or licensecheck.shwould find, instead of trying to build the twin from source. When that twin fails to start, the test prints its log. -
A world can carry its own timeline. A
--seedfile, or a complete world document posted toPOST /_rystic/seed, may addcredentials, binding presented credentials to actors, andevents: calls the twin makes on its own API at set sim times as one of those actors, through the same handlers and egress as your requests. A top-levelcredentialsoreventskey the twin used to ignore now takes effect.RYSTIC_PACE=<speed>plays the clock, and the timeline, in real time, and an event’satmay be+<duration>./_rystic/inject,--injectandRYSTIC_INJECTnow needstate_setinstead ofscenarios. -
A timeline call can hand what it minted to later events. A
callevent may addbind,{"<name>": "<JSON Pointer>"}, saving fields of its answer, such as an ID the twin minted; a later event uses one as${name}in a call’s path, headers or body or in a seed event’s record keys and values (a register name cannot be bound). A schedule naming a${name}no earlier call binds is refused at load, and an event whose binding call failed records an error instead of firing. -
github-pulls_open_queueplays a timeline. Over the hour after the pinned clock, tb-reviewer-login approves #41, tb-contributor thanks the reviewer on it, tb-maintainer requests changes on #42, retitles the draft #43 and squash-merges #41 intotb-base-branch, each through the REST API under a token the world binds to that login; advance the clock to watch the queue move. -
A clock jump fires each timeline event at its own time. When one
POST /_rystic/clockor paced tick passes several events, each fires with the clock at itsat, so the records it creates carry that time, and the clock then lands where the move asked. An event already due when the clock moves fires at the current time. -
A clock jump stamps each timeline event at its exact instant. When a clock move passes timeline events, the records each one makes carry exactly its
at, to the millisecond, whatever traffic came before it, so one jump leaves the same records as stepping the clock to each event in turn. -
A timeline call can carry raw bytes, XML or a JSON array. A
callevent may sendbody_textorbody_base64instead ofbody; the twin sends it as written, under the route’s own content type (XML, bytes, JSON or a form), so a scheduled call can upload an object’s bytes or post an XML document, and${name}fills inbody_text. Abodymay be a JSON array where the route reads one. A call that answers XML binds from its elements by JSON Pointer, such as/InitiateMultipartUploadResult/UploadId. -
A read-only collaborator cannot remove review requests.
DELETE /repos/{owner}/{repo}/pulls/{pull_number}/requested_reviewersrefuses a caller who can only read the repository with403 Must have write access to repository., as requesting reviewers already did. -
Refusals and issue search answer as GitHub does. A self-approval answers errors
["Review Can not approve your own pull request"], a review comment on a path outside the diff["Path could not be resolved"], an invalideventon review createUnprocessable Entityand on submitValidation Failedwith GitHub’s errors — each measured against GitHub. Issue search appliesreason:completed,reason:"not planned"andowner:, which it used to read as text. A search qualifier the twin does not evaluate still answers501, now in GitHub’s error envelope with amessage. -
GET /user/codespaceslists your codespaces. It answers{total_count, codespaces}with every codespace the caller created from a pull request, newest first, paged byper_pageandpageand narrowed byrepository_id; without a credential it answers401 Requires authentication. -
Other search types are refused, and what is out of scope is listed. GraphQL
searchwith atypeother thanISSUEorISSUE_ADVANCEDis refused with an error namingg_search_type_served, where it used to answer issues. Code browsing, code and commit search and issue subscriptions are recorded as out of scope under Limitations.
v0.0.11 — 2026-10-10
-
Seeded worlds read like GitHub’s own records. Every URL a seeded user, organization, membership, repository, commit, file, review, team or check run carries sits on GitHub’s hosts — the API ones rewritten to the twin’s origin, as created records already were — so a client that completes a record lazily (PyGithub’s
pull.user.name) comes back to the twin instead of failing onexample.invalid. A pull request’shead.repoandbase.repoare the whole repository object (fork,clone_url,ssh_url,pushed_atand the rest), itsuserand everyownerthe whole simple-user, andcommentsandreview_commentscount exactly the rows the comment routes list. The importedgithub-pulls_garrytan_gbrainworld keeps itsexample.invalidURLs. -
A body that is not JSON is refused, not applied as an empty edit.
PATCH /repos/{owner}/{repo}/issues/{issue_number}andPATCH /repos/{owner}/{repo}/pulls/{pull_number}answer bytes their JSON decoder rejects with GitHub’s400 {"message":"Problems parsing JSON"}before looking anything up, and write nothing — both used to answer 200 and bumpupdated_at. A truncated object is not that case: GitHub reads a body missing its closing brace as the object and a dangling value as a value its schema check refuses, and the test sends what no JSON grammar accepts. APATCHon a seeded pull request answers the projectionGETrenders, with all eight_linksand its derivedmerge_commit_sha, not the record as seeded. -
A pull request serves its diff and patch.
GET /repos/{owner}/{repo}/pulls/{pull_number}withAccept: application/vnd.github.diff(or.v3.diff) answers the unified diff as text underContent-Type: application/vnd.github.diff; charset=utf-8,.patchthe format-patch mbox of its commits, andapplication/vnd.github.full+jsonthe JSON body withbody_htmlandbody_text— including calls made by Octokit’spulls.get({mediaType:{format:"diff"}}). -
The Limitations say what the twin serves. The docs page and the HOWTO state where a seeded URL leads (to the twin, where
GET /users/{login}and every other route outside its coverage answers404), the headers it sends unlike GitHub (noETagor304, noX-GitHub-Request-Id,X-RateLimit-*only onGET /rate_limit, nocharseton JSON), the past-the-last-pageLink, the list query values and the requested-reviewers readback no round has measured, the truncated JSON bodies GitHub reads leniently, theDate:of a commit made through the API, and whatbody_htmlrenders from. -
The diff and patch representations read as GitHub writes them.
GET /repos/{owner}/{repo}/pulls/{pull_number}underAccept: application/vnd.github.diffnames eachindexline’s blobs by their first seven characters, and under.patchnumbers a lone commit[PATCH], carries thecreate modeline in the stat block and ends after the diff with no version trailer — measured byte for byte against the real service — so a tool that parses GitHub’sformat-patchoutput parses the twin’s. The one remaining difference is theDate:header of a commit created through the API: the twin stamps it in UTC, GitHub in the committing account’s time zone. -
A seeded draft pull request is mergeable. In
github-pulls_open_queue, the draft #43 answersmergeable: true, mergeable_state: "clean"— the settled values GitHub computes for a draft, measured against the real service on a full-suite round — where it answeredmergeable: null, mergeable_state: "unknown"on every read, so a merge bot that polls untilmergeableresolves now stops. A draft opened through the API already answered the settled values. -
The pull-request list falls back to GitHub’s defaults.
page=0,per_page=0, a non-numeric page and an unknownstateorsortonGET /repos/{owner}/{repo}/pullsanswer200with the defaults, as GitHub does, where the twin answered422 Validation Failed; and aheadvalue withoutuser:ororganization:is ignored rather than matched against the branch name, sohead=<ref>lists every open pull request as it does on GitHub. Each value is a test confirmed against the real service. An unknownstateon the issues list stays422, which is what GitHub answers there. -
A review comment’s review exists. A top-level comment through
POST /repos/{owner}/{repo}/pulls/{pull_number}/commentscreates the COMMENTED review itspull_request_review_idnames, soGET …/pulls/{pull_number}/reviews/{review_id}answers that review and the reviews list includes it, as GitHub does. -
A submitted review clears its author from the review request. Once a requested reviewer submits a review through
POST /repos/{owner}/{repo}/pulls/{pull_number}/reviews,GET …/pulls/{pull_number}/requested_reviewersand the pull request’srequested_reviewersno longer list them, as GitHub documents, sogh pr viewdrops their “(Requested)” mark and a bot waiting on the request sees it clear. A pending review leaves the request standing until it is submitted. -
What a pull request’s conversation page reads now answers.
GET .../issues/{n}/eventsandGET .../issues/{n}/timelinelist a pull request’s or issue’s events and its derived timeline (comments, reviews, review comments, commits);Accept: application/vnd.github.html+json,.text+jsonand.full+jsonanswerbody_htmlandbody_text;GET /repos/{owner}/{repo}answerspermissionsfor the caller; and everyavatar_urlleads to an image the twin serves itself.github-pulls_garrytan_gbrainnow carries every pull request’s conversation comments and issue events, and its collaborators are the logins GitHub marks as holding push, so other users read. -
Organization access is consistent. Membership and repository routes match the caller’s login and membership state without case sensitivity, ignore memberships without a user, and keep organization affiliation when the caller is also a collaborator. Organization admins receive admin permissions on their repositories; billing managers and pending invitations grant no repository access.
/user/orgs,/user/memberships/orgs, and/user/reposerror responses includestatus. -
Answers carry what GitHub’s response schemas fix. Where a handler leaves a field absent or null, the twin answers with the required constant, the required field’s documented default, or the variant tag that GitHub’s response schema fixes for that route and status, error envelopes included; a value the handler wrote is never replaced.
-
A body’s media type renders the same under its v3 spelling.
Accept: application/vnd.github.v3.full+json,.v3.html+json,.v3.text+jsonand.v3.raw+jsonanswer an issue’s, pull request’s, comment’s or review’s body exactly as the unversioned media types do:body_htmlandbody_textare one GitHub-flavored Markdown rendering ofbody, so a client that names the API version in its media type gets the rendered fields. -
Page links name the page size you asked for. On the page-numbered reads (
GET /search/issues,GET /user/repos,GET /user/orgs,GET /user/memberships/orgs, and an issue’seventsandtimeline), eachLinktarget carries theper_pagethe request sent, as GitHub does, even above the 100 items a page holds. Aper_pagethat is not a positive integer names the size served. -
Pull request mutations answer the pull request’s reviews.
mergePullRequest,convertPullRequestToDraft,markPullRequestReadyForReviewandcreatePullRequestanswerreviews,latestReviews,latestOpinionatedReviewsandreviewDecisionfrom the reviews submitted on the pull request, asrepository.pullRequestdoes, instead of always empty. -
X-GitHub-Api-Versionis checked on every route, as GitHub does.2022-11-28(the default when the header is absent) and2026-03-10are accepted; any other value answers400with GitHub’s error body before the operation runs. -
A read answers
If-None-Matchwith304. AGETcarries a weakETagwhere GitHub’s does; send it back inIf-None-Matchand an unchanged read answers304 Not Modifiedwith no body, so a client’s cache revalidates against the twin as it does against GitHub.GET /search/issuesandGET /rate_limitcarry noETag, as on GitHub, so a conditional request there reads the answer again.If-Modified-Sinceis not answered. -
Web URLs lead back to the twin.
html_url,_links.html,clone_urland every other URL ongithub.comor one of its subdomains (docs.github.com,codeload.github.com) now names the twin’s origin, asapi.github.comURLs already did;RYSTIC_PRESERVE_SERVICE_URLS=1keeps GitHub’s hosts. Throughrystic run --intercept,github.comand its subdomains route to the twin. -
Past the last page,
Linkpoints back at it. On the page-numbered reads (GET /search/issues,GET /user/repos,GET /user/orgs,GET /user/memberships/orgs, and an issue’seventsandtimeline), a page past the end names the last page as bothprevandlast, and page 1 asfirst, as GitHub does. -
Repositories and commit authors read as GitHub embeds them.
GET /user/reposlists each repository as GitHub’s full repository object with the caller’spermissions; a pull request’sbase.repoandhead.repo, on its reads and onPATCH …/pulls/{pull_number}, carry neitherpermissionsnorcollaborators; andGET …/pulls/{pull_number}/commitsnames each commit’sauthorandcommitterby GitHub’s 19-field user. -
A pull request’s
404carries only GitHub’s keys. A404on a pull request, or on its commits, files, comments, reviews, review comments, reactions or check-run annotations, answers exactlymessage,documentation_urlandstatus, as GitHub does, without theurlthe twin added.GET …/issues/{issue_number}/eventsanswers its404with the same three keys. -
GET /userandGET /rate_limitanswer what GitHub does. The authenticated caller reads its private-user view, withplan,disk_usage,collaborators, the private repository and gist counts,two_factor_authenticationandnotification_email, andGET /rate_limitlists every resource category GitHub answers,audit_log,audit_log_streamingandenterprise_token_inventoryincluded. -
The Limitations name the codespace timestamps. The docs page and the HOWTO say that a codespace’s
created_at,updated_atandlast_used_atare written in UTC, where GitHub writes them at the account’s UTC offset, so a client compares them as times rather than strings. -
A path ending in a slash answers
404, as on GitHub.GET /repos/{owner}/{repo}/pulls/, or any route with a trailing slash, answers GitHub’s404 Not Found(message,documentation_urlandstatus) where the twin served the route without the slash; drop the slash to reach it. -
GET /repos/{owner}/{repo}answers GitHub’s full repository object. The repository read carries all 96 of its fields, the merge-commit settings and the*_urltemplates among them, and names itsownerby GitHub’s 19-field user. Its URLs on GitHub’s hosts lead back to the twin, exceptgit_urlandssh_url, which keep GitHub’sgit://and SSH addresses. -
A push runs the repository’s CI checks. A push, and a pull request that opens, moves or reopens, now starts the repository’s own
.github/workflows: one check run per job asgithub-actions, named as GitHub names it, completed from the twin’s evaluation engine — a verdict the world already recorded for the same tree replays, a surrogate you register (POST /_rystic/eval/evaluators, run byrystic evaluator) evaluates, and anything else showsneutral. A push also renders GitHub’spushevent intoGET /_rystic/egress. -
CI evidence stays tied to the current submission. Agent-created check runs and results from older submissions no longer count as trusted RL reward evidence; rerequests retain their prerequisites, workflow conditions retain their context, and executable files keep their permissions. Evaluation documents now require schema version 2 and a task scope; import reference history before starting an episode, and pin command evaluators to a container image digest and platform. Local execution requires explicit trusted opt-in, while calibration requirements can specify sample counts, coverage and applicability.
v0.0.10 — 2026-10-05
-
Search applies the rest of GitHub’s grammar, and refuses what it cannot evaluate.
GET /search/issuesandgh pr list --searchnow apply negation (-author:,-label:,-is:draft), repeated qualifiers as GitHub combines them, the advancedAND/OR/ parentheses syntaxghsends, free text within:title|body|comments,head:,base:,comments:,no:label,review:,involves:,mentions:,commenter:and in-querysort:, each measured against api.github.com; an unknown login or repository answers GitHub’s422over REST and matches nothing over GraphQL. A qualifier the twin cannot evaluate answers501with anX-Rystic-Markerinstead of quietly broadening the result; REST search pages with GitHub’sLinkheader, so a client that follows it gets every page, and a pull request found by search or read through the Issues routes carriesdraftand its ownnode_id. -
A codespace’s
environment_idcomes from its own range. The first codespace you create gets81cebaf1-e762-4d0c-8000-000000000001rather than00000001-0000-4000-8001-000000000001. Ids still count up in their last digits and restart on reset; a test that pinned an old ID needs the new one. -
Seeded teams report
type: "organization". The teamsgithub-pulls_org_teamsseeds, whichGET /repos/{owner}/{repo}/pulls/{pull_number}/requested_reviewersreturns, carriedtype: "Team", a value GitHub never sends. They now carry"organization", GitHub’s ownership type for an organization’s team and the one the twin gives a team it was not seeded with, so a client typed on that field’s enum accepts them. -
The authenticated user’s repositories and organizations answer.
GET /user/reposlists the repositories the caller owns or collaborates on, with GitHub’svisibility,affiliation,type,sort,direction,since,beforeand paging parameters, andGET /user/orgslists the caller’s organizations. Both answer401without a credential, as GitHub does, sogh api user/reposandgh api user/orgsno longer 404. -
Organizations have members.
GET /user/orgslists the organizations the caller holds an active membership in,GET /user/memberships/orgsanswers those memberships with theirroleandstate, andGET /user/repos?affiliation=organization_memberreads the same membership.github-pulls_org_teamsseeds the organization its teams belong to,tb-org, withtb-owner,tb-reviewer-loginandtb-maintaineras members, and its teams’ URLs name it; every other world seeds none, so those lists stay empty there.
v0.0.9 — 2026-10-01
-
check.shfails fast on a port that never answers. Its port check and boot wait give each/_rystic/versionprobe two seconds, so an address where something accepts connections but never replies fails the check with a message instead of hanging. -
The repository, the rate limit, the API root and the authenticated user answer.
GET /repos/{owner}/{repo},GET /rate_limit,GET /andGET /userare served as GitHub serves them, so an agent’s first probes no longer 404 andgh auth statusreports a login instead of an invalid token. The rate limit reports a full budget that resets at the next hour of the twin’s clock, and URL templates in responses keep their braces, as GitHub writes them. -
gh pr listandgh issue listrun with their filter flags. With--author,--assignee,--label,--draftor--search, gh first asks forSearchType’s values, and the twin answeredField 'enumValues' doesn't exist on type '__Type'. It now answers with GitHub’s values, so the list runs through the twin’s search. HOWTO.md lists the qualifiers that search applies. -
Issue search over REST, and both searches apply date qualifiers.
GET /search/issuesanswers the world’s issues and pull requests withtotal_count, paging andsort. It and the GraphQLsearchthatgh pr list --searchsends now applycreated:,updated:,closed:andmerged:ranges,draft:andis:unmerged, so counting pull requests in a date window matches the world instead of ignoring the range.
v0.0.8 — 2026-09-30
-
The state UI shows the repository as GitHub. On Pro and Enterprise,
/_rystic/uiopens GitHub-style Pull requests and Issues pages for the twin — a pull request’s conversation and merge box, commits, checks and diff with review threads and check annotations — where you can act as any seeded user: comment, review, comment on a line, react, request reviewers, merge, close, lock and set a check run’s status through the twin’s own API, beside Actors & calls and Webhook events cards. -
Unmodified clients reach the twin at GitHub’s hostnames.
rystic run github-pulls-twin --interceptboots the twin behind the rystic CLI’s HTTPS gateway, andrystic exec -- <command>routes that command’s calls toapi.github.comandgithub.comto it:gh,git clone https://github.com/...,curland SDKs with their default base URL, when their transport honors the proxy and CA settings. -
Free boots the imported
garrytan/gbrainrepository.--scenario github-pulls_garrytan_gbrain(orRYSTIC_SCENARIO) now boots it on Free, andPOST /_rystic/resetputs back whichever world the twin booted — issues, comments, pull requests, reviews, check runs, pushed branches and git objects included, with the next issue and pull request numbers where the boot left them.
v0.0.7 — 2026-09-26
- HOWTO.md and
check.shstart from an API key. HOWTO.md leads withrystic login rys_…, says which worlds and control-plane routes Free, Pro and Enterprise reach, and corrects the auth rules: Codespaces need aBearerortokenheader, and a login the world does not seed is refused authored writes, merges, dismissals and review requests but not a PATCH, a check run or a reaction. SCENARIOS.md marksgithub-pulls_open_queueas the one Free world and keys a pull request file<number>#<path>, and both give the ID bands new records get.check.shruns the Free and Pro builds on your API key (RYSTIC_TOKEN, or the keyrystic loginstored) as well as the Enterprise build on its license file, looked up in the CLI’s order.
v0.0.6 — 2026-09-25
-
More complete pull-request responses and token authentication. Pull-request and review responses include missing user and repository fields. GraphQL pull-request creation errors retain the nullable payload alongside errors, and merge and review-dismissal refusals match observed response envelopes. Codespaces can be deleted by name. Clients can authenticate with either GitHub’s
Bearerortokenauthorization label. -
Self-serve on the metered edition replaces the free edition. A Rystic account’s token runs the metered build:
rystic login rys_…once, thenrystic runordocker run -e RYSTIC_TOKEN=…. It carries every scenario and control-plane route your plan includes, and bills active 30-second blocks against the plan’s allowance. The fixed-world free download and its usage report are gone.
v0.0.5 — 2026-09-24
gh repo cloneworks with gh 2.101.0 and later. That release’s clone query selectsRepository.sshUrlon the repository and its parent and stops at the schema when the field is missing, so against 0.0.4 the clone failed before it started. The twin now answerssshUrlas GitHub spells it,git@<host>:<owner>/<repo>.git, on every repository.
v0.0.4 — 2026-09-23
- A request GitHub’s schema refuses gets GitHub’s answer. In 0.0.3,
eight routes answered a required field left out, a value of the wrong
type, or one outside its enum with
422 {"message": "Validation Failed", "errors": [...]}. GitHub refuses those before the resource sees them, with422 {"message": "Invalid request.\n\n<lines>"}and noerrorskey:"title" wasn't supplied.,For 'properties/body', 5 is not a string.,For 'properties/side', "UP" is not a member of ["LEFT", "RIGHT"].The twin now answers the same way when you create or edit an issue, create or edit an issue comment, lock an issue, open a pull request, create a review comment, or react to one. Validation Failed witherrors[]stays where GitHub sends it, for a value the schema admits and the resource refuses: a blank title, an unknown branch, a duplicate. Editing an issue with an unknownstate_reasonanswers GitHub’s own422 {"message": "Please specify a valid state reason."}.
v0.0.3 — 2026-09-22
-
Every world is a real git repository. Each scenario’s repository is served at
/{owner}/{repo}.git/on the twin’s port:git clone http://localhost:8080/tb-owner/tb-repo.gitandgit fetch origin refs/pull/{n}/headwalk git’s dumb HTTP protocol,git pushspeaks the smart one, and every sha the API reports is a commit you can check out. Pushing to an open pull request’s head branch moves the pull request — head sha,commits,files, asynchronizenotification — exactly as GitHub does; pushing to a protected branch is refused. A pull request’sfiles[].patchand its commits’ diffs now come from the same commits, so the two never disagree. -
The
ghCLI works against the twin.POST /graphqlanswers the queries and mutations theghporcelain sends, sogh pr list,gh pr view,gh pr checkout,gh pr checks,gh pr comment,gh pr review,gh pr merge,gh repo clone,gh issue create,gh issue listandgh issue commentrun unchanged, and every mutation writes through the same handler its REST route uses, so whatghdid shows on the REST surface. Point it at the twin with environment alone —ghspeaks plain HTTP togithub.localhostand honors a proxy, and git honors the lowercase spelling:export GH_HOST=github.localhost GH_TOKEN=ghp_anything \ HTTP_PROXY=http://localhost:8080 http_proxy=http://localhost:8080 gh pr list -R tb-owner/tb-repoThe GraphQL surface is the subset
ghspeaks, not GitHub’s schema; a field outside it answers GitHub’sundefinedFielderror.gh issue view --commentslists the comments the store holds. -
The API’s writes land in the repository you clone. Creating a pull request, retargeting its base, merging it and updating its branch all move the served git repository now, so a
git fetchafter any of them finds what the API described: a merge is a commit onorigin/<base>carrying the head’s changes — withmerge_methodmergethe base and the head are its two parents, whilesquashandrebaseland single-parent commits — and a pull request opened on a branch you pushed carries that branch’s commits and files.gh pr mergefollowed bygit pullbehaves as it does against GitHub. Fetching still changes nothing —refs/pull/{n}/mergeis derived when a client asks for it and never written — so a clone leaves the world exactly as it found it. -
Issues and their comments over REST.
/repos/{owner}/{repo}/issueslists and creates them,/issues/{n}gets and updates one —statecloses and reopens it, with astate_reason— and/issues/{n}/locklocks and unlocks its conversation. Comments are/issues/{n}/commentsto list and create,/issues/commentsfor a whole repository’s, and/issues/comments/{id}to get, edit or delete one. Pull requests are issues here as they are on GitHub: they come back in the list and by number, each carrying apull_requestkey, and/issues/{n}/commentsis where a pull request’s conversation comments live — the routegh pr commentwrites to, distinct from the diff-anchored review comments. Issues and pull requests draw their numbers from one sequence per repository, and a repository with issues disabled answers 410 on the issue routes; pull-request numbers and their comments are unaffected. The eighth world,github-pulls_issue_tracker, seeds five issues and a commented pull request around that shared sequence. -
A real repository to review:
github-pulls_garrytan_gbrain. The first imported world — the 300 most recently updated pull requests ofgarrytan/gbrainas GitHub served them (121 open, 15 merged), their 5,693 changed files with GitHub’s own diffs, 6,266 check runs, 140 real users, and the repository itself as a packfile:git clone http://localhost:8080/garrytan/gbrain.gitdownloads one 85 MB pack in about two seconds,git fetch origin refs/pull/N/headreaches any imported pull request, andgh pr checkoutlands on the API’s head sha. Ids, logins and shas are GitHub’s.import_repo.py --git --gzipis how another repository becomes a world. -
Who may write is one rule, answered as GitHub answers it. A caller’s standing on a repository — the owner is admin, a collaborator holds its declared permission, everyone else the world knows holds read, a login the world does not seed has none — is consulted by every guarded write. Merging, dismissing a review and requesting reviewers refuse a credential naming nobody with
401 Bad credentialsand a caller without write access with403, where before they accepted both; the writes that record an author no longer create issues, pulls, reviews or comments withuser: null. Standing is asked of the resolved user, never theX-Rystic-Actorlabel, so a review can no longer be requested from the pull request’s own author. Every world seeds all four documented logins, andtb-contributoris a user without write access, as the name says. From the same review round: an issue’s and a pull request’s IDs are one rule, and created rows mint into their own bands; error bodies are GitHub’s three families, and an unknown path answers in the service’s voice; a pull request’s mergeability, its reviews and its check runs report what the world actually holds.
v0.0.2 — 2026-09-18
-
Two worlds whose diffs carry a real program.
github-pulls_bot_pr_brokenseeds a merged pull request (#7) whose single file is a working Kalshi market-maker bot —files[].patchis the whole ~150-line Python file — and an open pull request (#8) that changes one line so the buy and sell legs swap sides, with a redbot-smokecheck run whose failure annotation points at that line.github-pulls_bot_pr_queueopens five such pull requests (#11–#15), one bug each: swapped sides, a syntax error, the wrong orders path, prices in cents, a required order field dropped. The twin has no contents API, so this is how code comes out of it: read the base, apply the change, run the result. Seven worlds now;--list-scenariosnames them. -
The bot ships as plain files too.
bots/bot.py, the fivebots/variants/*.pyandbots/test_bot.pyride the archive beside the binary, andrystic features github-pulls-twindescribes them underreference bot. The test runs the bot for 30 s against akalshi-twinin itstight_bookworld and requires a bid and an ask resting at once, no crossed quote and nothing left resting; every variant is held to failing that. -
SCENARIOS.md. One page naming every baked-in world and what it is for, with IDs, the cast and the bot-world recipe — the cheat sheet the Kalshi and Stripe twins already carried. -
Writes on a seeded pull request now land. In 0.0.1 every baked-in world keyed its pull requests and branches by bare number and name while the handlers look them up as
<owner>/<repo>#<number>and<owner>/<repo>#<name>, so aGETon a seeded pull request resolved butPATCH, merge, review dismiss, reply and reviewer requests on it answered 404. The worlds are re-keyed the way the handlers read them, andGET /_rystic/registersstates the key grammar. One consequence that matches GitHub: opening a second pull request on a head that already has an open one is 422; a head whose pull request is closed takes a new one.
v0.0.1 — 2026-09-17
The first release of the GitHub Pulls twin.
-
A local GitHub pull-request surface for your review automation. 37 routes across 13 resources under
/repos/{owner}/{repo}: pull requests (list, create, get, update, files, commits, merge, update-branch), reviews and their submit and dismiss events, review comments with replies and reactions, requested reviewers, check runs with annotations and re-requests, and codespaces. Point your GitHub client’s base URL at the twin — PyGithub, octokit andghall take one — and your existing suite runs against it unchanged. Nothing you send it counts against a rate limit, lands in a real repository, or needs a token. -
Five worlds baked in.
--list-scenariosnames them: an open queue of four pull requests, a finished review thread with reactions, a head sha with four check runs standing, a merged-and-closed backlog, and org teams requested for review.--scenario <name>boots straight into one, so a test starts from the review state it needs with no seeding step. -
Review as one user, approve as another. GitHub’s review rules depend on who is calling — an author cannot approve their own pull request, only a user with write access can dismiss a review. The twin reads the caller from the
X-Rystic-Actorheader and applies the seeded permissions, so the permission branches of your integration are testable without a second real account. -
It never reaches the network, and it needs no token. Any
Authorizationheader is accepted, and the twin dials nothing — which is the point: a suite that hitsapi.github.comfails when the hourly limit is spent, and one that creates throwaway repositories leaves them behind. -
Seed the world your test needs.
POST /_rystic/seedtakes a register map — repositories, branches, commits, users, teams, pull requests, reviews, comments, reactions, check runs — and lands it directly.GET /_rystic/registerslists what this twin accepts a seed for; a register the model does not carry is refused with a status rather than silently dropped. -
The same seed twice is the same world twice.
POST /_rystic/resetempties the twin,POST /_rystic/seed-rngpins the generator so IDs, node_ids and merge shas repeat run to run, andPOST /_rystic/clockfixes the twin’s notion of now socreated_atandupdated_atare values your test chose. Two identical seed flows leave byte-identical/_rystic/inspectdumps.
What is measured, and what is not. Fidelity is scored differentially
against real api.github.com on a per-run throwaway repository, per response
field, not by eyeball: the last full-suite round was 136 of 138 probes on
2026-09-06, and the suite has since grown to 140 probes. Known shortfalls
are listed in HOWTO.md under Limitations — there is no git behind the
records, so diffs and file lists are seeded facts and merging does not
rewrite branch history; no webhooks are delivered. Rounds are re-run daily
and each release states its own number.
Not in this release. Issues, issue comments, labels as a resource, commit statuses, branch protection and repository or organization administration are not modeled.