GitHub actions I use for CI.
The reusable workflows declare a least-privilege cache-mode on each job.
node,node-majors,node-esm,browser, andbun: test jobs declareread, so callers must not setcache-modetononeorwrite-onlyon the calling job or workflow — the run would fail validation. Callers that passbuild-output-dirmust also not cap atread: thebuildjob relies on the trigger's default (write) to save the build output.pretestandrebase: every job declaresnone, which is compatible with any caller setting.
A GitHub App, installed on the repositories to manage, lets scheduled jobs in this repo work across all of them, with no per-repository workflow.
- Create a GitHub App (no webhook needed) with these repository permissions: Contents (read), Metadata (read), and Issues (read and write), which its bot uses to keep the workflow drift report issue up to date, and to react to and reply to commands on it. For the commands, it also needs Pull requests (read) for a dry run, and Contents (write), Pull requests (write), and Workflows (write) to open PRs. For
/merge, it also needs Checks (read), Actions (read), and Commit statuses (read), to read every check suite, check run (and its annotations), workflow run and job, and commit status on a PR’s head (Contents and Pull requests read cover the rest of what it reads, including a PR’s commits); Contents (write) and Workflows (write), to fast-forward a PR’s base branch (GitHub requires both to update a ref, and Workflows for one whose commits change workflow files); and Variables (read and write), for the merge queue, which is kept in an Actions variable of this repo. Keeping the App’s PRs up to date uses those same Contents (write), Pull requests (write), Workflows (write), Checks (read), Actions (read), and Commit statuses (read) permissions, to rebuild a PR’s branch, comment on it, and read its CI. Without one of them, a command (or the queue’s run) fails, naming the permissions it needs. When a permission is added to an existing App, each installation’s account must approve it (in its settings, under “Applications”, or from GitHub’s email about it) before the App can use it. Until it does, the workflow drift report, the commands, and keeping the App’s PRs up to date skip that installation, when the installation (as GitHub lists the App’s installations) lacks a permission they need that the App itself has, rather than failing: each run’s log has a notice naming the installation and its account, and the drift report, and a command’s reply, list the account as waiting on permission approval. An installation lacking a permission the App itself doesn’t have isn’t waiting on its account, so it fails, naming that permission, since only the App’s settings can fix it; and a token GitHub refuses anyway still fails, as any other error does. - Install it on each account, for all or selected repositories, including this repo, which holds the report issue. Installing it is what opts a repository in. (Installing it on more than one account requires making it public, which lets anyone install it; see
FLEET_ACCOUNTS.) - In this repo, set the
FLEET_APP_IDActions variable to the App’s ID or client ID, and theFLEET_APP_PRIVATE_KEYActions secret to one of its private keys. WithoutFLEET_APP_ID, the fleet workflows (fleet-drift.yml,fleet-commands.yml, andfleet-merge.yml) are skipped. - Set the
FLEET_ACCOUNTSActions variable to the space-separated accounts (users and organizations) whose installations may be processed, egljharb es-shims inspect-js. Installations on any other account are ignored. When unset, only this repo’s owner is allowed. - Optionally, set the
FLEET_SOURCE_ACCOUNTSActions variable to the space-separated accounts whose repositories may be checked as the source of a fork (see below). - Set the
FLEET_GIST_TOKENActions secret to a token that can read and write the gists of the user it belongs to: a fine-grained personal access token with the “Gists” account permission set to “Read and write” (it needs no repository access), or a classic one with thegistscope. The workflow drift report keeps its full report in a secret gist of that user’s; without this secret, it fails before checking anything, saying so.
Optionally, .github/ljharb-actions.json on a repository’s default branch, and ljharb-actions.json at the root of the default branch of an account’s .github repository, which sets defaults for every repository in that account:
{
"workflows": { "node-twenties.yml": true, "rebase.yml": false },
"keep": { "node-pretest.yml": ["skip-engines"] }
}workflows:falseto opt out of the workflow drift report;true, or an object mapping workflow file names totrue(expected: report it if missing) orfalse(ignored: never report it), to opt in.keep: an object mapping workflow file names to arrays of input names (each matching^[\w-]+$) that/fixkeeps, with the repository’s own values (and comments, trailing or on lines of their own), in each call that passes them, when it rewrites that file from its template. A call’s counterpart in the template is its job’s namesake, when that calls the same reusable workflow; otherwise (eg, a renamed job), it’s the one other call of that workflow, when each file has exactly one left. When which call a kept input goes to is ambiguous, or what the current file passes can’t be known (it, or a call’swith:in it, isn’t YAML the App understands; see the workflow drift report), or a kept input can’t be added to the rewritten file,/fixskips that file, saying why, rather than drop the input, and the workflow drift report lists that as the repository’s error. Each input must be one that the reusable workflow of this repo’s that the file’s template calls declares: naming any other (or a file with no template that calls one) is a config error, naming them, and those are never kept. It only applies to one repository, so it can only be set in a repository’s own file: setting it in an account’s defaults is a config error. Keeping the App’s PRs up to date adds to it when restoring an input fixes a failing PR.
A repository’s own file takes precedence over its account’s defaults, key by key:
a key the repository doesn’t set uses the account’s default,
a false from the repository always wins,
and when both set an object, the two are merged,
with the repository’s entries winning.
The account’s defaults are only read if the App can read its .github repository:
install it there too, or keep that repository public.
When that repository is private, its defaults apply only to the account’s private repositories (see Private repositories).
Forks are never checked. A fork’s source is checked instead (by the workflow drift report only, since the App isn’t installed on it) when it’s public, unarchived, not ignored, its owner is in the FLEET_SOURCE_ACCOUNTS Actions variable (space-separated accounts; unset means none) and has no installation of the App, and its default branch has a commit whose committer is this repo’s owner. The App can’t see anyone’s permissions on a repository it isn’t installed on, so that commit stands in for write access; it also counts commits from a PR merged with a merge commit, so FLEET_SOURCE_ACCOUNTS is the real gate. A source’s account defaults aren’t read. A private fork’s source isn’t checked, since that it’s checked at all would show the fork exists.
This repo is public, and so is everything its fleet workflows output: run logs (including the inputs GitHub shows for each step, and job names), annotations, summaries, step outputs, and the drift report (including its gist, which, though secret, anyone with its link can read) and its replies. So private repositories the App is installed on are processed like any other, but nothing about any one of them is ever output; at most, how many there are, and how many had each kind of outcome (and for commands, not even that):
- None is ever named, nor are its files, contents, branches, PRs, errors, or tool output shown. Each has no log line of its own: its outcome is at most counted, in a line after all the public repositories’, so that neither where one would fall among them by name, nor how long one took, is shown.
- An error is only shown for a public repository; a private one’s is at most counted, and never fails anything else. An error not about any one repository (eg, an installation that couldn’t be read) shows only its HTTP status or GraphQL error types, never its message, which could quote what it couldn’t read.
- A command fixes (or, in a dry run, plans) the private repositories its filters match, exactly as it does public ones, so a private repository can be named. But since its filters can be as narrow as one repository, its output says nothing about them at all, not even how many matched or failed: whether any private repository matched, exists, or failed is never confirmed. Its reply always ends with the same sentence saying that any matching private repositories were processed too, so a reply for filters that match only private repositories is identical to one for filters that match nothing. They’re fixed after every public one, with no log line, warning, or annotation of their own, and a private repository’s failure never fails the run; it only shows in that repository itself (eg, as its PR not being opened or updated).
- A private
.githubrepository’s defaults apply only to its account’s private repositories: any effect on a public one (a file it requires, ignores, or forbids; or its being invalid) would show in that one’s public report or fix, and so would show what’s in the private file. Its account’s public repositories are treated as if it had no defaults; to share defaults with them, make the.githubrepository public. When those defaults are invalid, a private repository’s error only says so, not what’s wrong. - An installation on an account that isn’t allowed is skipped without naming the account, which may have installed the App only on its private repositories.
An account’s .github repository is skipped by the workflow drift report unless its own .github/ljharb-actions.json
(that is, .github/ljharb-actions.json inside the .github repository, eg { "workflows": true })
sets workflows; the account defaults in its root ljharb-actions.json don’t opt it in.
Archived repositories are always skipped.
fleet-drift.yml, in this repo, runs daily
(and on workflow_dispatch, optionally as a dry run).
It compares the workflow files of every repository the App is installed on against the templates in fleet/drift/templates,
keeps the full report in a secret gist (see FLEET_GIST_TOKEN, in Setup),
and keeps a single issue in this repo up to date with a summary of the discrepancies that links to it
(reopening it as needed, and closing it when there are none).
It only reads; it never changes another repository.
The issue is written by the App’s bot (eg, ljharb-fleet[bot]), with a token for only this repo, that can only write issues; the job’s own GITHUB_TOKEN can’t write issues at all.
The report issue is the newest one, open or closed, marked as the report, and opened by either the App’s bot or github-actions[bot]: a report opened before the App wrote it was opened by github-actions[bot], and since GitHub can’t change an issue’s author, the App’s bot adopts it, editing it from then on. An issue anyone else opens is never written to, even if it’s marked as the report.
If the App can’t get that token (without the Issues permission, or before its installation has approved it, or when it isn’t installed on this repo), the run summary is still written, and then the run fails, saying what the App needs.
rebase.yml,node-pretest.yml, andrequire-allow-edits.ymlare expected in every repository.- node tests go in
node.yml, split into one workflow file per decade of node majors (node-aught.ymlfor< 10, including io.js, thennode-tens.yml,node-twenties.yml, and so on) only when one file’s job matrix would exceed GitHub’s limit of 256 jobs. Which of them a repository should have comes from itspackage.json’sengines.node, the matrixtypeits node workflow files use (minorsunless they all usemajors), and the released versions that thenode/matrixaction uses:node.ymlwhen all of the versions that satisfyengines.nodefit in one file; otherwise, one file per decade from the first with such a version, until the last, open-ended one fits. Any other such file is unexpected. Withoutengines.node, they’re only compared when present. - Template placeholders (
{{range}},{{type}},{{command}},{{label}}) match any one-line value, so legitimately per-repository values aren’t drift; a placeholder that appears twice must have the same value both times. - An input a workflow file passes (in a job’s
with:) to one of this repo’s reusable workflows (ljharb/actions/.github/workflows/<file>@<ref>, asjobs.<id>.uses) isn’t drift when that reusable workflow declares it underon.workflow_call.inputs(as.github/workflows/<file>in this repo has it), the template doesn’t pass it itself, and its value is a scalar (or empty): eg,skip-engines: trueforpretest.yml. One whose value is a mapping or a sequence (or an alias of one) still drifts, since it might be anything. Workflow files are parsed as YAML (withyaml), with each\r\n(or lone\r) read as a\n, so eachwith:’s keys are exactly what YAML says they are, however they’re written (eg, bare, single-quoted, or double-quoted, with their escapes); the file is matched with such inputs removed (each from the start of its key’s line, or of the comments on lines of their own before it, to the end of what the parser says is its value, with any trailing comment), and awith:left with nothing else in it removed too, so any other input (one the reusable workflow doesn’t declare) still drifts. Comments on lines of their own in awith:are a repository’s own, like trailing comments (each belongs to the input after it, or, after the last one, and indented more than thewith:, to that one), and are ignored too, including awith:with nothing but such comments in it; whatever the parser says is part of a value (eg, a#line within a block scalar, or a quoted scalar) is part of it. This fails closed: a file the parser reports any error or warning in (eg, an input passed twice, a tab in indentation, a quoted scalar or flow collection that never ends, or that’s indented no more than its key, a tag it can’t resolve, or an alias of an anchor that isn’t there), or with a character YAML 1.1 (but not 1.2) breaks lines at (U+0085, U+2028, or U+2029), or that what’s left of after removing inputs is (eg, an alias of an anchor in a removed input), is matched as it is, nothing ignored, and so drifts; and so is anywith:that isn’t empty or a block mapping, with no anchor or tag, whose every key is a string with no anchor or tag, alone on its line before its:(eg, a flow-stylewith: { … }, awith:with an anchor or that’s an alias, an explicit?key, a key with an anchor or a tag, or an anchor on a line of its own). A<<merge key is just a key (<<), which no reusable workflow declares, so it drifts. When a file drifts anyway, its diff shows exactly what/fixwould write, which keeps only the inputs the repository’skeepnames. - Other workflow files are not reported.
- Nothing is ever left out of the full report, whatever its length: every failure, every repository with discrepancies, and every diff, whole. It groups repositories by account, each a collapsed table. Every file it lists links to that file (a missing one, to its template, when there is one), and each drifted file expands to show its full diff.
- The full report is in one secret gist of
FLEET_GIST_TOKEN’s user: the one whose description is exactlyljharb/actions fleet workflow drift report, and that holds nothing but the report’s files. It’s found on every run, and updated in place (it’s created when there’s none; a public gist, or one with any other description or files, is never written to). When there are no discrepancies, it says so. - GitHub cuts a gist file short past 1 MB, so a report that’s longer is split, only between rows, into as many files as it takes, each under 1 MB (
workflow-drift-001.md,workflow-drift-002.md, and so on; a table that continues into the next file is reopened there), each written in a request of its own; files a previous, longer report needed are deleted. The issue then says how many files there are, and links each. - Dry runs write the full report too, since it’s where a dry run’s results are, but to a gist of their own (described
ljharb/actions fleet workflow drift report (dry runs)), so a dry run never changes what the issue’s link shows. - If the gist can’t be written, the issue and the run summary are still updated, saying so (without a link), and then the run fails.
- The issue, and the run’s summary, are a short summary that always fits in GitHub’s limits: the counts, a link to the full report, the failures, and, for each account, how many of its repositories have discrepancies, and how many files are missing, unexpected, or drifted. When there are too many failures, or accounts, or files of the full report to link, for that, it counts them instead, and the full report has them all. The issue ends with a collapsed legend of the commands that act on it.
- Private repositories are only counted, never named, since the issue, the summary, and the gist are public. A run that couldn’t check every repository says so, and never closes the issue; that includes the repositories of an account whose installation is waiting on it to approve the App’s permissions, which the report names (an account is never private), on a line of its own rather than as a failure.
fleet-commands.yml, in this repo, runs on every new comment on an issue here, and acts only on a comment on the workflow drift report whose first line is one of:
/fix <filters>: in each matching repository, open (or update) a PR that makes its workflow files match the templates./dry-run fix <filters>: reply with what/fixwould do (which PR it would create, update, or reopen, and with which files), without changing anything./merge <filters>: in each matching repository, merge the App’s bot’s open PRs from itsfleet/workflowsandactions-lock/relockbranches once their CI has passed; see Merging./dry-run merge <filters>: reply with what/mergewould do with each of those PRs (merge it now, queue it, or neither, since its CI failed or it needs updating), without changing or queueing anything.
At least one filter is required, since every repository at once is too broad. Filters are separated by spaces (or commas), and are case-insensitive; each is one of:
owner/name: a repositoryowner: an account- a workflow file name (
*.ymlor*.yaml): only that file missing,drifted, orunexpected: only that kind of discrepancy
/merge takes only repositories and accounts: a workflow file name or a kind of discrepancy gets a reply explaining the commands. Filters of the same kind match any one of them; filters of different kinds must all match. For example, /fix ljharb es-shims rebase.yml missing adds rebase.yml wherever it’s missing in ljharb’s and es-shims’ repositories, and changes nothing else. A repository with nothing matching the filters is only listed when it’s named. Anything else starting with / gets a reply explaining the commands.
They act only on the report issue: the one marked as the report, and opened by the App’s bot or (for the report it adopted) github-actions[bot]. The App’s bot checks the commenter’s permission, and reacts and replies, with a token for only this repo, that can only write issues (and read its metadata, which is all checking the permission needs); without the App’s Issues permission, the run fails, saying so, and does nothing else.
Only people with write (or maintain, or admin) permission on this repo can run them: a command from anyone else gets a reply saying so, and does nothing else. A bot’s comment is never a command, so the App’s own replies (which, unlike GITHUB_TOKEN’s comments, do start workflow runs) never run one. Every command from someone allowed gets a 👀 reaction while it runs, swapped when it’s done for 🚀 if it worked (including a dry run, or nothing to fix), or 😕 if it was invalid, a public repository couldn’t be fixed, or the run failed (GitHub has no ✅ or ❌ reaction). One that runs gets a reply with a table of what happened in each public repository, which is also the run’s summary. Nothing is ever left out of a reply: one too long for a comment goes on in as many more as it takes, each saying which part it is, split only between rows (a single row, or a quoted command, too long for any one comment is shown as its source, in marked pieces). A reply over 1 MiB is too long for a run summary, which then has as many of its comments as fit, whole, and links the comments with the rest (or, if they couldn’t be posted, says so); the reply is never in the run’s log, which never mentions anything a command names. Private repositories that match are fixed too, but are never listed, counted, or otherwise mentioned, beyond one sentence that every such reply ends with whether or not any matched. The run fails if any public repository could not be fixed, after trying every other one; a private repository’s failure only shows in that repository.
Commands run one at a time, in order. GitHub keeps only one waiting behind the one running, though: a command posted while one runs and another waits cancels the waiting one, which then gets no reaction and no reply, so post it again once the first is done.
Only the repositories the App is installed on are fixed; fork sources never are. In each, /fix works on a fleet/workflows branch, and its PR into the default branch:
- Missing files are written from their templates, drifted ones are rewritten from their templates (keeping the values of their placeholders, where it can recover them), and unexpected ones are deleted. When a node test file is added after an existing one whose version range is open-ended, that range is closed where the added file’s starts. A file that can’t be written (eg, with no template, or no way to know its node version range) is left as it is, and listed as such.
- An open PR from that branch is updated, and a closed, unmerged one is reopened (its branch first restored at the PR’s head commit, with a plain push, if it was deleted, since GitHub only reopens a PR whose branch is there); once one is merged, the next
/fixopens a new one. The PR is always exactly one commit, by the App’s bot, whose only parent is the default branch’s current tip: every update rebuilds it from there, planning the whole change again from what the default branch has now. When the branch already has exactly that change, as one commit on that tip, nothing is pushed. See The App’s branches for how the branch is overwritten, and when it never is. - A workflow file (or a directory on the way to it) that is a symbolic link is never written through: a linked file is replaced with a regular one, and a linked directory fails that repository.
- When it rebuilds an open (or reopened) PR whose branch’s
.github/ljharb-actions.jsonkeephas entries the base didn’t have when the branch was made (inputs put back when its CI failed), it keeps those inputs too, with the base’s values, and keeps them in the PR’s.github/ljharb-actions.json(the base’s, with them added, as 2-space JSON; unless that isn’t a JSON object, with an objectkeep, if it has one), so that rebuilding the PR never removes them again. - The commit is the App’s own, and changes only the files being fixed (and
.github/ljharb-actions.json, for what it keeps). If the branch has commits by anyone else, nothing is pushed: remove them, or delete the branch, and run the command again. Since every update rebuilds the PR, a later/fixfor that repository replaces what the PR changed with what that/fixplans: to keep a file in the PR, filter by repository rather than by file.
/merge merges only open PRs in the repositories the App is installed on (never fork sources), that aren’t archived, whose head branch is fleet/workflows or actions-lock/relock in the repository itself (never a fork’s), and that the App’s bot opened. It must be exactly one commit, whose only parent is its base branch’s current tip, as every PR of the App’s is (one that isn’t needs updating, which the App does every 10 minutes, so it’s reported, not queued). That commit must also have been authored and committed by the App’s bot (matched by the GitHub user GitHub links each author and committer to, or, when it links none, by the bot’s noreply email, <id>+<slug>[bot]@users.noreply.github.com); a PR with a commit by anyone else is reported (“it has commits by someone else; review and merge it yourself”), and never merged or queued. GitHub lists at most 250 of a PR’s commits, so a PR with 250 or more can’t be shown to have no others, and is reported the same way. The last commit listed must be the head commit that’s merged, so that the commit checked is the one merged. This guards against accidents, not adversaries: a commit’s author and committer are unverified git metadata (the App commits with plain, unsigned git), so anyone who can push to the branch can give their own commits the bot’s noreply email, which GitHub then links to the bot. A real guarantee would need the App to make its commits through GitHub’s API (which signs them), and /merge to require every commit’s signature to be verified. A PR is merged only once CI has fully finished, successfully, on its head commit:
- every GitHub Actions workflow run on it has completed as a success or skipped: a job that only starts once others finish (by
needs, or in a matrix an earlier job outputs) has no check run until then, so without this, CI could look done before it is; - every check suite has completed, so that an app that has registered a suite but not yet made its check runs can’t be missed; a completed suite must have concluded as a success or skipped, except that one with no check runs at all that concluded
neutralor with no conclusion is ignored (GitHub makes such an empty suite for an app that never reports anything on the commit). Many apps (eg, Codecov, Renovate, Travis CI) also leave a suite with no check runs queued forever, on every commit, so an unfinished suite with no check runs is pending only until nothing on the commit has happened for 15 minutes (since it, or any other such suite, was created, and since any check run completed or any workflow run or status last changed), and is ignored after that. An unfinished suite with check runs is always pending; - every check run, of every check suite (the latest run of each check, across every page of them), has completed as a success or skipped;
- every commit status is a success;
- no step of a GitHub Actions job behind one of those check runs failed, even when
continue-on-errorhides that failure from its job and its check run. GitHub’s Actions API does not show this in the step itself: it reports a failedcontinue-on-errorstep’sconclusionassuccess(as seen on real runs). What does show it is thefailureannotation the runner leaves on the job’s check run (eg, “Process completed with exit code 1.”), so anyfailureannotation on a GitHub Actions check run fails CI. That’s conservative: an::error::written by a step that passed fails it too. A step whose own conclusion is a failure, a timeout, or cancelled also fails it, as does a GitHub Actions check run whose job can’t be found.
With no check runs and no statuses at all, CI hasn’t started yet, so it’s pending, never passing; anything not yet completed is pending too. Any other conclusion or state (eg, failure, timed_out, cancelled, action_required, stale, neutral, startup_failure, or an error status) fails it.
What can’t be seen can’t be waited for: a commit status that hasn’t been reported yet, or a workflow (or another app’s check suite) that GitHub hasn’t created yet at all, isn’t there to be pending, so once everything that is there has passed, the PR is merged. Likewise, an app that makes its first check run in a suite more than 15 minutes after everything else on the commit went quiet (eg, Codecov, if coverage is uploaded that long after the rest of CI finishes) is missed. Conversely, an app that leaves a suite unfinished forever after making check runs in it keeps the PR queued until it’s dropped after 24 hours.
A PR is merged by fast-forwarding its base branch to its head commit, never forced, which GitHub then marks the PR as merged by: since its one commit’s only parent is the base branch’s current tip, that merges exactly that commit. When the base branch has moved on, the PR needs updating, which waiting won’t fix: it’s reported, not queued, and the App rebases it within 10 minutes (run /merge again after that). Never use GitHub’s “Update branch” on it: its commit isn’t the App’s, so /merge would never take the PR, and the App would never touch it again. A refused update (eg, by a branch protection rule) is reported with only its HTTP status. Each repository is read with an installation token for only it, that can only read, and merged with another, that can only push (and change workflow files), asked for only when there’s something to merge.
A PR whose CI is still pending is queued, and fleet-merge.yml, in this repo, checks the queue every 10 minutes (and on workflow_dispatch), before keeping the App’s PRs up to date. Each run checks every queued PR again: one that’s been closed or merged, or whose account is no longer allowed, is dropped; one whose head commit or base branch has changed since it was queued is dropped (only what /merge was asked to merge is ever merged, and only into the branch it was asked to: run /merge again); one with a commit by anyone but the App’s bot (or 250 or more commits), whose CI failed, or that needs updating (that isn’t one commit on its base’s tip), is dropped and reported; one whose CI passed is merged; one still pending stays queued, for up to 24 hours after /merge, after which it’s dropped and reported (eg, when a status will never be reported), so that nothing stays queued forever. A run that resolved any public PR replies once on the issue with what happened to each; a run that resolved nothing says nothing.
- The queue is kept in the
FLEET_MERGE_QUEUEActions variable of this repo, as JSON, read and written with an installation token for only this repo that can only read and write its variables: unlike anything a workflow outputs, a variable is private, and the queue may name private repositories. Each entry is the PR (its repository, number, and the head commit and base branch/mergesaw), the/mergecomment, the issue, and when it was queued. A PR is queued at most once: a later/mergefor it replaces its entry. The queue is kept well within GitHub’s 48 KB limit for a variable; a PR that doesn’t fit isn’t queued, and is reported as such. fleet-merge.ymlsharesfleet-commands.yml’s concurrency group, so the queue is never read or written by both at once, nor is a PR’s branch pushed to by both. GitHub keeps only one run waiting in a group, cancelling any other that was waiting there, so a queue run first checks (with anactions: readGITHUB_TOKEN) that nofleet-commands.ymlrun is running or waiting, and skips its turn if one is. A command posted in the few seconds between that check and the queue’s job joining the group can still be displaced, as a third command would displace it; its job never starts, so it gets no 👀 or reply: post it again.- A
/mergewhose PRs were all resolved at once swaps its 👀 for 🚀 (all merged, or nothing to merge) or 😕 (any failed CI, needed updating, or couldn’t be merged or queued). One that queued a public PR keeps its 👀 until the queue resolves its last public PR, then swaps it for 🚀 (all of them merged) or 😕. - Private repositories that match are merged and queued like any other, but never named, counted, or otherwise mentioned in a reply, the log, or the run summary, and never affect a command’s reaction (a private PR’s outcome only shows in that repository). The queue’s replies always end by saying that any queued private PRs were also checked. Whether a run checks anything does show whether anything is queued, though not what.
Every fleet-merge.yml run (every 10 minutes), after the merge queue, looks at every open PR from the App’s branches (fleet/workflows and actions-lock/relock) that the App’s bot opened, in every repository the App is installed on (never a fork source) in the allowed accounts that isn’t archived, ignored, or a fork. Which repositories have one is read with one GraphQL query for every 20 of them (counting each one’s open PRs from each branch), so with none at all, the run ends right there, having done nothing else. What to do with each PR is decided from GitHub’s API, exactly as /merge decides it (its head commit, its commits’ authors and committers, its base branch’s tip, and its CI), so a PR with nothing to do is never cloned; one that has to be rebased is cloned with only its base branch and its own commits, and their trees (each file’s contents are fetched only when they’re read), and one whose inputs are restored with only its head commit and its parent, the base branch’s tip. Each PR:
- is left for the next run if its branch moved while it was being looked at (including between reading it from the API and cloning it).
- is never touched if it has any commit that the App’s bot didn’t both author and commit (or 250 or more, more than GitHub lists), matched exactly as
/mergematches them: it says so on the PR, once for each head commit (a hidden marker in its comment, naming the head commit, keeps it from saying it again). - is rebased, when it isn’t one commit whose only parent is its base branch’s current tip: its change (all of its commits’, squashed, for a PR from before this rule) is replayed onto the base’s tip, as
git rebasewould, as one commit by the App’s bot, with its first commit’s message. If that conflicts, it’s left as it is, and says so on the PR, once for each head commit and base branch tip: update it (eg, with/fix), or close it. If the base branch already has everything it changes, it’s left as it is, and says so on the PR (that it can be closed), once for each head commit and base branch tip. - for a
fleet/workflowsPR that’s one commit on its base’s tip, has its CI read, exactly as/mergereads it. Pending or passed, nothing’s done. Failed, the App restores what the PR removed that could have caused it: for each workflow file that failed (the file of each workflow run on the head commit that failed, of each failed job’s run, and of each run with a step whose failurecontinue-on-errorhides), in a way its base branch’s tip doesn’t, and that the PR changed, the inputs the base branch’s version passes to one of this repo’s reusable workflows (that it declares) that the PR’s version no longer does, in that call’s counterpart (matched as forkeep), are put back in it, with the base’s values and comments (trailing, or on lines of their own), and added to the repository’s.github/ljharb-actions.jsonkeep(created if needed, keeping everything else in it, as 2-space JSON), all in the PR’s one commit, rebuilt. The next CI run then decides. A failure the PR didn’t cause isn’t grounds for restoring anything: so, only once the PR’s CI has failed, the base branch’s tip’s CI is read too (every failure, including thosecontinue-on-errorhides, even when something else failed), and a workflow file failed in a way its base’s tip doesn’t only when one of its jobs (by its check run’s name; or, for the workflow run itself, by its conclusion, egstartup_failure) failed on the PR’s head commit but not in that file on the base’s tip. When the base’s tip has no runs of that file, or one still running, it can’t show how that file fails there, so the file’s failures are the PR’s. Since what decides that the PR’s CI failed leaves out failurescontinue-on-errorhides, and whatever’s still running, once anything failed, before deciding that every workflow file that failed fails the same way on its base’s tip, every failure on the PR’s head commit is read too; while any of its workflow runs is still running, nothing is done yet. When every workflow file that failed on the PR’s head commit fails the same way on its base’s tip, nothing is restored, and the PR says so (listing those files), once for each head commit, since it needs a person. When there’s nothing (left) to restore (the inputs are already back, or what failed isn’t a file the PR changed), which call to put an input back in is ambiguous, an input can’t be put back (afterwards, the PR’s version, parsed again, doesn’t pass it in that call: eg, itswith:is flow-style, or isn’t YAML the App understands), or.github/ljharb-actions.jsonisn’t a JSON object (with an objectkeep, if it has one), the PR says that it needs a person, once for each head commit, and nothing else is done until its head changes, so it never oscillates. What’s put back is checked first, and nothing is put back unless all of it passes: each input’s lines, on their own, must be YAML of exactly that one input, with every line after its key’s blank, a comment, or indented more than its key (so none can be a key of anything else); and, afterwards, the file must still be YAML the App understands, each suchwith:must have exactly the inputs it had and those put back, and the whole file, without those inputs (and without awith:that’s new), must have exactly the values it had (with every alias resolved where it is: eg, an anchor in a restored input that would change what an alias after it is, is refused). A file whose every line ends with\r\nkeeps them; any other is written with\ns. - once it’s said, for its head commit, that a PR needs a person, is left as it is until its head commit changes; and once it’s said, for its head commit and its base branch’s tip, that a PR conflicts, or that its base already has everything it changes, is left as it is until either changes: in both cases, without being cloned again, since that comment of the bot’s (found by its hidden marker, naming the head commit, and the base branch’s tip for those two, read from the API along with the base branch’s tip) is what decides it. So when the base branch moves on, the App tries to rebase the PR once more, and says so again only if it still conflicts (or its base still has everything it changes); a PR that needs a person must be updated (eg, with
/fix) to be looked at again.
For example, a fleet/workflows PR that drops skip-engines: true # … from node-pretest.yml (which pretest.yml declares, so it’s no longer drift, but which a PR made before that might still drop) fails pretest.yml’s engines job; the next run puts the line back, and adds "keep": { "node-pretest.yml": ["skip-engines"] }, so later /fixes keep it. A /fix that rebuilds that PR before it’s merged keeps it too: it plans the PR with the keep entries the PR’s branch adds to its base’s (where the branch was made from), and keeps them in the PR’s .github/ljharb-actions.json, so it doesn’t remove them again.
Each PR is read, rebuilt, and commented on with an installation token for only its repository (Contents, Pull requests, and Workflows write; Checks, Actions, and Commit statuses read). Private repositories are kept up to date too, after every public one, but nothing about them is ever logged, and their failures never fail the run; a comment on a private repository’s own PR is only seen by those who can see that repository. A public repository that fails (eg, a rejected push) is warned about, and fails the run.
Every PR the App opens (from fleet/workflows, by /fix and the 10-minute run) is always exactly one commit, by the App’s bot, whose only parent is its base branch’s current tip: so /merge can fast-forward the base branch to it, merging exactly that commit, and a reviewer sees one change. Keeping it that way means replacing the branch’s commit whenever the change or the base branch changes, which a plain push can’t do, so the App force-pushes, but only like this:
- only to its own branches (
fleet/workflowsandactions-lock/relock, named infleet/lib/branches.mjs), never anywhere else; - only with
git push --force-with-lease=refs/heads/<branch>:<the commit it read>, so that if anything was pushed to the branch since the App read it, the push is rejected (and reported, not retried), instead of losing it; - only when every commit on the branch that isn’t on its base branch was authored and committed by the App’s bot, matched by the bot’s noreply email (
<id>+<slug>[bot]@users.noreply.github.com, which is what the App commits with) in the commit itself, in a clone (unlike/merge, which asks GitHub, and matches the GitHub user GitHub links each author and committer to, when it links one); a branch with anyone else’s commit is never overwritten, and that’s reported instead (to rebuild it, remove those commits, or delete the branch); - and only when the branch doesn’t already have exactly that change, as one commit on that tip; then nothing is pushed.
A branch that doesn’t exist yet is created with a plain push, as is a deleted branch restored for a closed PR before it’s reopened. A closed PR is only reopened once nothing would refuse its update: not when its branch has anyone else’s commits, nor when the branch is no longer where it was read (which would fail the lease), so it’s never reopened only to be left as it was. This guards against accidents, not adversaries: authorship is unverified git metadata, so anyone who can push to the branch can give a commit the bot’s noreply email.