The canary and the Workflow test¶
The canary¶
The tests replace the engine, and the Action test runs everything but it, so the path that matters most, from an issue through the engine to the checked answer, ran only when someone opened an issue: here that is rare, and every repository that uses the action runs it with its own secret. A renamed output of the engine's action, a host that the firewall blocks, an expired secret, or a secret or variable that no longer reaches the called workflow would show as issues without an answer, in every repository at once. The Canary, .github/workflows/canary.yml, runs the whole path every Monday, after every change on main of what it runs, and by hand:
- It calls the reusable workflow as every repository does. Its job is the call of the template
templates/.github/workflows/issue-assistant.yml, with the same name, the same permissions and the engine's secrets passed by name, whichtests/test_templates.pykeeps equal to the template's. Only the workflow differs,./.github/workflows/reusable-issue-assistant.ymlof the commit, and the inputcanary. So the run takes the path of production: the job that calls can't name an environment, and the jobanalyzegets the value of the secret from the environmentissue-assistant; the called jobs see the variableISSUE_ASSISTANT_AIof the repository; and the engine runs on the firewall runner, with the steps and pins of every repository. Only withcanarydo the engines also accept a run that a bot started, as the canary's own runs after a push are. - The issue exists nowhere.
tests/fixtures/canary-issue.jsonholds an issue of the bug report form of this repository and a comment, as GitHub's REST API gives them. Withcanary, the environment variableISSUE_ASSISTANT_FIXTUREgives them tocontextandapplyin place of an issue on GitHub; the other issues, the discussions, the labels and the latest release come from GitHub, as for a real issue. The jobeventtakes the canary's issue in place of that of an event and sets no labels, and with a fixtureapplyis a dry run whateverdry-runsays, so nothing reaches an issue, although the token ofapplymay write, as in every repository. A test keeps the issue fitting the form and the labels of this repository. - Where a repository's run skips the AI, the canary fails. The job
eventfails when the called workflow doesn't seeISSUE_ASSISTANT_AIset toon, which the settings bot sets here from.github/repository.toml, and the jobanalyzefails without the engine's secret or without an answer. A repository's run would skipanalyzeorapplythere, and the canary would stay green. - Only this repository passes
canary. The jobeventfails a call withcanaryfrom another repository, which has no canary's issue. Every step and value of the canary in the reusable workflow namesinputs.canary, which every other caller leavesfalse, and a test keeps the jobanalyzeof the discussions equal to that of the issues without them. - The repository of the caller is that of the workflow. The Canary calls the workflow of its own repository, so it shows that the secret and the variables reach a called job, not whose they are when the two repositories differ: that a called job sees the caller's rests on GitHub's documentation, as decision 0015 says.
- The firewall policy is this repository's. The runner enforces
.github/egress-firewall.yaml, the blueprint's copy of the template of the release that it pins, so a change oftemplates/.github/egress-firewall.yamlreaches the canary with the blueprint's next update. - Only
mainruns it. Only the default branch may use the environmentissue-assistant, so no pull request runs the canary. Every update of the engine's action merges on its own once the required checks of its pull request passed, engine contracts among them, as.github/renovate.json5says, and the push of the merge starts the canary with the new version; the release waits for it, as the next section shows. The engine's action runs for a schedule or a run by hand, not for a push, so after a push the jobstart, whose token may only start workflows, starts the canary as a run by hand and waits for its result. GitHub may answer 404 for the run that it just named in the first moments after the dispatch, so the job asks for it every 5 seconds before it watches it; when GitHub still doesn't show it after a minute, the job ends with a warning that links to the run, rather than with a red run ofmainwhile the canary passes. - It measures the wait for the AI's runner. Its job the AI's wait for its runner runs
scripts/canary_queue.pybeside the call and reads the jobs of its own run until the job of the AI starts:analyzeon the firewall runner, or, when the variableISSUE_ASSISTANT_AI_RUNNERnames another runner,analyze-in-reservein its place, while the run skips the other one. The wait counts from the end of the jobevent, which both need, so neither the event nor an earlier canary counts; the summary shows it with the name of the job, with a warning above 10 minutes and a failure above 30, also while the job still waits, and with the advice that fits its runner, which the PR coach explains from.github/checks.project.toml. Onmainthe job then opens the issue of the bot-health bot for the Canary at once, since the run, and with it GitHub's mail and bot-health's look, ends only when the job of the AI does. - A red canary is seen. GitHub mails the failure of a scheduled run to whoever last changed its schedule, here the maintainer. A run that the job
startstarted has nobody to mail, so that job fails with it, and GitHub mails the failure of the push's run to the person who merged; a canary that a later push's canary replaced fails nothing. Every red run also shows under Actions, and keeps the release back.
The canary before the release¶
Nobody looks at an update of an engine before it merges: Renovate opens a pull request for each engine a week after its release, a minor or major version too, and it merges on its own once its required checks passed (decision 0030). So the release waits for the canary instead of a person who watches it:
- The release's pull request has a check of its own. The Canary of main, the job of
.github/workflows/release-gate.yml, is a required check of.github/repository.project.toml. It runs on the pull request of the release bot alone, from its branchrelease-please--…of this repository; on every other pull request GitHub skips it, which counts as passed, without a runner. - It waits while a canary runs.
scripts/release_gate.pyreads the runs of the Canary onmainevery minute while one of them is unfinished, such as the one that the push of the latest change started, which the release bot's update of its pull request follows. Then the latest run decides: the check passes only when it passed. A run that a later one replaced never is the latest once that one ended, so a latest run that was cancelled is one that a person stopped, and the check fails for it, as for a push whose jobstartcouldn't start the canary. After 100 minutes, the check fails and can run again. - A red canary keeps every release back. Not only one with the update of an engine: the release ships what is on
main, and a red canary means that the path of an issue through the AI is broken there, or not shown to work. The summary of the check names the run and its commit, and the PR coach explains it from.github/checks.project.toml. A revert of the update, or a later one that fixes it, runs the canary again and updates the release's pull request, whose check then passes; a canary that failed for a moment needs another run of it, on Monday or by hand withgh workflow run canary.yml. The release bot's auto-merge then merges a release of dependency updates. - Every canary lets the release decide again. The check decides when the release's pull request gets a new head. The last job of every canary of
mainthat runs the AI, the release decides again, runsscripts/release_gate.py again: when the check of the release's pull request ended with another verdict than this canary, a failure after a canary that passed or a pass after one that failed, it runs the check again, which waits until this canary has ended and decides on it. Nobody has to run the check by hand, and a canary that fails after the check passed holds the release back, unless it merged before. - The other engines aren't run with a key. The canary runs Claude, the engine of this repository. Codex, Gemini and Copilot rest on the week that every update waits and on engine contracts; decision 0030 names what stays a risk.
The Workflow test¶
The tests and actionlint only read the reusable workflows, the Action test runs the action by itself, and the Canary calls only the workflow of the issues, only on main and with the engine's secret. So nothing ran the jobs that every repository calls on a pull request: a wrong needs, if, output or permission would show first in those repositories, after a release, often without a red run, since a job that its condition skips passes. The Workflow test, .github/workflows/workflow-test.yml, calls each of them on every pull request that changes what they run, and on every push to main; "Re-run all jobs" starts it again:
- Each call is that of its template. One job calls each reusable workflow of the commit,
./.github/workflows/reusable-*.yml, with the permissions of its template intemplates/anddry_run, whichtests/test_workflow_test.pykeeps equal. On a pull request from a fork GitHub gives every job a token that can only read, which a dry run needs; on one of this repository a token that may write, so a test makes sure that every command of the action that writes gets thedry_runof its call. - The labels, the findings and the lifecycle run in full.
sync-labels,findingsandsweepread this repository as every repository's morning run does, and write nothing. A dry run also waits for no run of its group and replaces none, so the pull requests don't queue behind each other or behind the real sweep. An alert of code scanning onmainthat the Findings workflow there hasn't reported yet fails the dry run of the findings, as it fails that workflow, whose record in the cache the dry run reads and never writes; since a pull request can't close it before its merge, the check passes then, with a warning, as long as open alerts are all that its job names. - The issues run without the AI. The input
smoke, which only this repository may pass, is the counterpart of the Canary's: the jobeventtakes the issue oftests/fixtures/canary-issue.jsonand sets no labels,analyzeandanalyze-in-reserveare skipped, andapplychecks the answer oftests/fixtures/smoke-answer.jsonin place of the engine's, as a dry run whose summary shows the comment. A test runsapplywith this answer for that issue, so a change of the checks that refuses it fails before the workflow does. The engine, its secret and its runner stay the Canary's. - The discussions run without the AI too. A pull request is no event of theirs, so the call passes
smoketo them as well: the jobeventtakes the discussion oftests/fixtures/smoke-discussion.json,analyzeandanalyze-in-reserveare skipped, andapplychecks the answer oftests/fixtures/smoke-discussion-answer.jsonas a dry run. As in a run by hand,applyanswers it whatever this repository's categories; a test runs it too. - Every job checks the commit of its workflow first. A reusable workflow can't pin the commit that it is part of, so each job checks out the issue assistant of the commit that GitHub names in its context,
job.workflow_repositoryandjob.workflow_sha(decision 0015). Without them,actions/checkoutwould take the caller's own repository at its default branch, and the job would run the caller'saction.yml, or fail with a puzzling error. The step before the checkouts stops the job unless GitHub names a repository and a full commit SHA, and says why; a test keeps it in every job of every reusable workflow. - The last job is the check. A job that its condition skips passes as well as one that runs, so the job reusable workflows, the check that
mainrequires, reads the jobs of its own run withscripts/workflow_test.py: each job passed, with the step that writes the plan of its dry run, or was skipped where its call has to skip it. GitHub lists the steps of a job some time after the job ends, once for minutes (#288), so while it lists a job that has to run as not yet completed, or without the steps that it ran, the check reads the jobs again every 20 seconds, for up to five minutes, before it judges, and so it does after a read that may succeed when it comes again: one without an answer, or with an error of GitHub's servers, a timeout or a rate limit. A read that GitHub refuses with any other error of the client, such as 403 or 404, fails the check at once and names the status, GitHub's message and where to look (#327): every read again would repeat it. A job that ended earlier with at least one step listed, such as one that failed before its plan, fails the check at once. A job that the step Make sure that Harden-Runner blocks stopped shows as stopped by Harden-Runner; when only such stops failed, the check says so in the words of that step, and the flaky-test bot counts no flaky test (#392). Its summary shows a table of every job, and the PR coach explains a failure from.github/checks.project.toml. A pull request that changes nothing that the dry runs run or read, such as one of the documentation alone, skips the calls, and the check passes in seconds.
The runners that the action is tested on¶
The workflows of the templates run on ubuntu-24.04, yet a repository's own job may call the action on ubuntu-latest or on the free ARM runners. So the jobs action and egress of the Action test each run on ubuntu-24.04, ubuntu-26.04 and ubuntu-24.04-arm, side by side. The leg on ubuntu-24.04 keeps the name of its job and alone lints the templates. Another leg names its runner in its check, such as action (ubuntu-26.04), and its part other-runner says what to compare. check prefers PyYAML, so each leg of action also runs it once with PyYAML hidden, and the real yq of that image reads the set-up. A leg on another runner isn't a required check; one that fails on main gets an issue from the bot health.
The first step of action.yml compares python3 with the version of decision 0003, since the script may not get far enough to do so. The job old-python runs the action on Python 3.11 and passes only when it failed. It runs that step once more, to read that its output names the version and holds no traceback.