.github/workflows/

August 23, 2026 ยท View on GitHub

One guards a pull request, one guards main, one guards develop, three publish a package, one writes a release page and one serves the site. ls .github/workflows/*.yml is what says how many, and this page carried the figure instead until the count and the directory disagreed: portability.yml ran the operating system matrix, was named in no diagram here, and is now a job of Arena develop, which is the same omission read twice. A number no assertion holds is the defect that rule exists to stop.

pull_request -> main|develop  Arena PR
push to develop               Arena develop         and the operating system matrix
push to main                  Arena main            builds, and saves that build
   |
   +-- on success             Publish arena-react      restores it
   +-- on success             Publish arena-angular    restores it
   +-- on success             Publish arena-contracts  restores it
   +-- on success             Publish arena-mcp        restores it
   +-- on success             Publish the site         restores it
   +-- on success             Release notes            describes the tag this commit reaches

Arena PR

Two stages, build then test, and the fan-out is in the second one.

changes            which layers this diff reaches
   |
build              bun run build:release, which assembles too, then one cache entry
   |
   +-- test-core       always            the core and arena gates + the suites under scripts/
   +-- test-react      if react          the react gates + the two React invocations
   +-- test-angular    if angular        the angular gates + the suites off the ngc emit
   +-- test-tailwind   if tailwind       the tailwind gates
   |
pr-gate            the only required check, and it waits for every job above

The operating system matrix is not here, and Arena develop carries it. The reason is the event: this one fires on every push to an open pull request, and Arena takes pull requests from anyone, so a matrix here bills three operating systems per revision of every contribution rather than once per change that was accepted.

build is one job because the build is one thing. The steps run in an order the graph derives and the order is not decorative: the Tailwind preset compiles against the token CSS, and every layer's components read the class names that step writes. A build job per layer would have each of them redoing most of what the others did. ../../scripts/build/AGENTS.md carries how the order is decided.

Every workflow runs bun run build:release, never bun run build. Locally the build keeps what nothing has moved under, which is the point of it. In a workflow that would be the wrong kind of green: the step after it proves the build idempotent with git diff --exit-code, and a build that did nothing satisfies that by doing nothing. build:release passes --force --assert-full, so every step runs and a run that kept anything fails on its own.

.cache/ is not in the paths actions/cache restores, and that is the load-bearing part. The list is frameworks/**/*.generated.*, the two layer dist/ trees, frameworks/angular/build, dist/contracts and dist/site, and check:graph holds it to every artifact a clone does not carry: ../../scripts/graph/AGENTS.md says how. It was a list nothing held until check:site ran here over a dist/site no job had handed it. Adding .cache there would hand the next job the graph's recorded state and turn the whole gate from a full run into an incremental one, in silence. --assert-full is what would catch it; this paragraph is what explains the failure to whoever added the path. Caching a build's inputs is a non-goal, and carrying its outputs is not the same thing: .cache/ is restored nowhere in this directory, so no workflow here ever builds incrementally. What a restore hands over is a finished tree, and a job that takes one either uses it whole or builds the whole thing itself.

The four names are on the test stage, where the layers are genuinely disjoint. A gate belongs to exactly one of the five domains check-all.ts sorts by, and the jobs partition that set: core takes the core and arena domains, and the other three take their own. check-all.test.ts asserts the partition, so a gate cannot join GATES and run in no job.

core runs on every change, and that is not caution. The arena domain is where the cross-layer gates are: check:api, check:behaviour, check:compliance, check:structure, check:dimensions, check:layer-independence, check:focus-trap. Each of them reads more than one layer, so none of them is a React question or an Angular question. And scripts/lib/arena/behaviour-contracts.test.ts asserts the React component count by literal value: a change confined to frameworks/react/ breaks a suite under scripts/.

Which layers a diff reaches is decided by scripts/ci/arena/changed-layers.ts, not by a path filter written here, because that module has a suite and a YAML filter does not. Its least obvious rule is the one worth reading: a Tailwind edit routes to both other layers, because each compiles something that layer emits.

pr-gate is the single required check. A job skipped by an if reports success to branch protection, so requiring test-react directly would be satisfied by a React change that failed to route. pr-gate runs with always() and reads needs.*.result, which no routing decision can skip. It is green when every result is success or skipped, and red on failure or cancelled.

Being the single required check makes its needs list the whole gate, and a job missing from it is a job whose failure branch protection never hears about. That is not hypothetical: the operating system matrix was a workflow of its own, needs cannot name a job in another workflow, and pr-gate was therefore green over a red macOS for as long as the arrangement lasted. The matrix is a job here now, and check:portability holds the list to every other job in this file rather than to a copy of it kept in step by hand.

portable is the one job that does not take the build cache, and it must not. It asks whether bun run build works on a machine that is not this one, so a job handed the Linux build would be answering a question nobody asked. It builds on each of its three runners and then compares the result to what the Linux tree committed, which is what git diff --exit-code is doing in a matrix leg.

A leg that fails without failing the gate is continue-on-error, and the flag comes from one place. scripts/ci/arena/supported-os.ts declares blocking per platform with its reason, the matrix sets continue-on-error: ${{ !matrix.blocking }}, and a failing leg with that flag hands needs a result of success. So the flag is the single edit that decides whether a platform gates, and pr-gate needs no clause of its own about any of them: a leg declared blocking turns a red operating system into a merge request that cannot land.

Arena main

One job, and deliberately not the fan-out. It runs every gate and then the whole suite through bun run ci:summarize, which takes the invocation from testStep() in check-all.ts and appends the two junit flags. check-all.ts stays the one place the test invocation is written down, and the run summary carries a table of passes per domain.

A domain that owns suites and reported no case fails the run, as does a tree that contributed nothing and a case belonging to no domain. A reporter that quietly dropped a suite would otherwise print a confident table of zeros.

It saves its build, and that is the only reason the three publish workflows and the site are cheap. All four fire on this workflow's success, and each would otherwise build the same commit again, which is one push to main building Arena five times. The key is arena-build-<os>-<sha>, the commit rather than the run, because the run that reads it is not this one and does not know its number. The save sits directly after the idempotency check, which is the last moment the tree is known to be exactly what the build wrote and nothing a gate has since touched.

The eviction rule is what makes the commit the right key rather than a happy one. A cache goes unread for seven days and is gone, so a release cut long after its push finds nothing, and a key naming anything looser would have found something to hand over instead. On a re-run of this workflow the key already exists and actions/cache/save reserves it, fails, and logs a warning, so a re-run keeps the first run's build and stays green.

Arena develop

Two jobs. verify is the same five steps as Arena main, in the same order, and it is the only job here that is a copy of another. It exists because work lands on develop before it lands on main, and what lands there is not always what a pull request tested. Arena PR runs on pull requests into develop as well as into main, and each run tests the head at that moment; a merge commit resolved afterwards, and a push straight to the branch, are verified by this workflow and by nothing else.

portable is the operating system matrix, three legs on their own bun run build, and this is where the question is asked. check:portability holds the legs equal to ../../scripts/ci/arena/supported-os.ts and reads this file to do it. A merge request from develop to main is opened on a green develop, so a platform this matrix reddens is one that never reaches the branch the packages publish from.

It is a separate file rather than a second branch on Arena main's trigger, and the reason is the name. Every workflow downstream of a release fires on workflow_run of the workflow named Arena main, so a develop push carrying that name would raise the publish question about a branch that is not main. Their branches: [main] filter refuses it, but the refusal is one file away from the event; a name of its own puts the answer in the workflow that asks.

verify caches nothing, so bun install is cold on every run, and it saves nothing either. Arena PR caches because a pull request is pushed to repeatedly and its four test jobs each need the one build, and Arena main because four later workflows read what it built. verify runs once per merge and is read by nobody, where a cache saves a fraction of a run it would also have to be kept honest across. portable restores a bun install cache per operating system and saves none of the build, because what it is asking is whether a fresh tree builds on that platform.

Assembling is part of the build rather than a step of its own. bun run build:release passes --assemble, so all four packages are part of the run the workflow already makes. Dropping the assembly would not skip package work: check:packages reads no manifest and passes while saying so, which is a quieter green rather than a faster one, and check:consumer assembles a missing dist/ itself. Assembling nothing is not publishing nothing, and nothing here publishes.

Publish arena-react, Publish arena-angular, Publish arena-contracts, Publish arena-mcp

A release moves the version in several places and the tag is the one the rest are pinned to. It lives in .claude-plugin/plugin.json, .claude-plugin/marketplace.json and the README's artifact list; because the plugin is served from the tag, source.ref must name it and the tag must exist on the release commit. Forgetting the ref fails silently: the marketplace advertises a new version while Claude Code keeps fetching the old tag and resolves the old version, so nothing errors and the update is never offered. bun scripts/check/arena/check-release.ts is what refuses that combination, and versioning_steps.md is the order the moves are made in.

The fourth publishes no language. @dravensoft/arena-mcp is a server, and it is the one package here that declares a runtime dependency: the component libraries promise none, and a server nobody imports into a screen has no business putting one inside them. It carries no document at all, which check:mcp asserts rather than a comment claiming it, because the corpus it serves is the one inside whichever layer a consumer installed. Packed from dist/mcp for the same reason the contracts package is packed from its own directory: nothing is assembled under frameworks/ for it.

The third of them publishes no layer. @dravensoft/arena-contracts carries the three contract levels as JSON and nothing else, for a platform target outside this repository, and its consumer is a Gradle or SwiftPM build with no Node at all. That is why its manifest declares no bin and no engines, and why it is packed from dist/contracts rather than from a layer: nothing is assembled under frameworks/ for it. Its guard entry in package-inputs.ts deliberately inherits none of SHARED_INPUTS, because five of those eight are false for a package with no stylesheet, no CLI and no component map, and inheriting them would republish it whenever a Tailwind manifest moved.

A build on another platform needs no npm client to consume it. A published tarball is a plain HTTPS URL under registry.npmjs.org, which is the whole reason a registry was chosen over an asset attached to a tag: an asset carries no version of its own, so the guard's first question, is this version already published, cannot be asked at all.

Each workflow fires on a green Arena main, guards, and usually does nothing.

Each is also dispatchable by hand, and that path exists because the automatic one has a gap nothing in this repository can close: workflow_run reaches only a workflow already registered on the default branch, so the push that first puts one there cannot dispatch it, and re-running that push replays the original event rather than asking the question again. A release whose event is missed that way has no other way through. A manual run is safe for the same reason an automatic one is: the guard and check-release.ts both run, so the answer to "is there anything to publish" is reached identically whoever asked.

The guard asks two questions in order. Is plugin.json's version already on the registry? Then there is nothing to do, which is almost every push. Otherwise, has anything this package carries moved since the tag of the version that is on the registry? If not, this package keeps its version while Arena moves on.

The baseline is that tag rather than the previous commit, and that matters: a layer can change in one commit and the version bump land in another, so asking only about this push would mean the change is never published at all. What each package carries is scripts/ci/arena/package-inputs.ts, whose suite holds the list to what the assemblers actually read.

A spec names a directory, and the guard asks the assembler which of it ships. The suites beside the sources, the prompts, the demo entries and the prose about a directory all sit inside frameworks/react/, and none of them reaches a tarball, so --carried puts excluded() -- the assembler's own predicate, in ../../scripts/lib/arena/package-exclusions.ts -- to every path git hands back. The question is asked of the part of a path inside the spec that reached it: a spec naming a file is read rather than walked, and scripts/build/react/build-react-package.ts is the assembler of the package it belongs to, sitting under a directory called build, which is one of the names that walk skips.

An empty list of paths stops the run. git reads no pathspec as every path, so a guard whose script died answers "everything moved" and republishes a tree nothing touched -- which is what happened at 10.0.1 and 10.1.0, where package-inputs.ts reached typescript through the assembler and a job that installs nothing resolved a major the tree does not pin. It was silent because the call sat in < <(...), and set -e never sees a command inside a process substitution. The list is now read into a variable whose failure is checked, and check:workflow-scripts refuses the import that made it fail.

Whatever it answers, the guard writes that answer to the run summary: the version on the registry, the version in this tree, the decision, and the reason for it. The common answer is that there is nothing to publish, and an answer readable only by opening a log is one nobody reads. These runs are not jobs of Arena main and never appear in its panel, because a workflow_run workflow is a separate run; each publish job is on its own workflow's page, and the summary is what that page says without being unfolded.

When the guard says yes, the publish job runs check-release.ts first, so a version bump pushed without its tag is refused loudly rather than published quietly. Then it takes the build Arena main already made of this commit, holds the manifests, and packs. The tarball and a small record of what was published go up as an artifact, because a packed tarball is byte-identical to what leaves the machine and is the only account of "what shipped at this version" that does not require trusting the registry.

Restoring that build is a read, and a workflow_run run is allowed nothing else. Only push, workflow_dispatch and a handful of their kind may write to the default branch's cache scope; every other event that resolves there, workflow_run among them, gets read access and no more. That is exactly the shape this needs: Arena main is a push and writes, and every workflow that follows it reads.

A miss is expected rather than exceptional, so the build stays in the file behind an if. Seven days unread evicts the cache, and the hand dispatch above can ask about a commit whose run is long past. There are no restore-keys, so a miss is a miss: a prefix fallback would hand this job the assembled tree of a different release and pack it under this version. What a miss costs is the build this change was written to avoid, which is the right price for the rare case and the wrong one for every push.

Authentication is a trusted publisher over OIDC: no token lives in this repository, and provenance is attested automatically, with no --provenance flag. The file name of each workflow is its identity, exactly and case-sensitively, because that is what the publisher configured on npmjs.com names. Renaming one revokes that package's right to publish.

The publish step is the one thing here that is npm and not Bun, and that is a capability and not a preference: bun publish does not speak OIDC, so a tarball leaving through it would be unattested. Every job installs, builds, gates and packs with Bun, then takes Node and an npm the step itself refuses to run below, because provenance from an npm older than that is attested by nothing.

Configuring a publisher for a name that has never been published is where a new package starts, and it is the step this repository cannot do. npm requires the organisation or user, the repository, the workflow filename and, for any configuration created after 20 May 2026, at least one explicitly selected allowed action; earlier ones were set to npm publish alone and did not have to choose. Whether npmjs.com accepts a publisher for a name with no versions on it is not documented either way, so the fallback is worth knowing before starting rather than halfway through: publish the first version by hand with a token, then configure the publisher and delete the token.

The one error tolerated is cannot publish over the previously published versions. The registry read path the guard uses lags a successful publish by several minutes, so a re-run inside that window sees a version that is published and reports it as absent. Any other failure is red.

Publish the site

The site is build output, so publishing from a branch is not an option: the kitchen sinks and every playground are what .gitignore keeps out of the tree on purpose. It restores what Arena main saved for the same commit, the same hand-off the three package workflows use, and falls back to a build of its own on a miss.

bun run build:site copies rather than rewrites, so a page served from the domain is the page a clone serves, and bun run check:site holds every href and src in the output to a file that is there. It needs a browser, because the card a link preview reads is rendered from the site's own tokens rather than exported by hand, and it takes the one pr.yml already takes: browser-actions/setup-chrome with CHROME_PATH deliberately unexported, since check:portability fails when a second workflow names it.

Release notes

It follows a green Arena main, like the four workflows above it, so a page announcing a release is never written over a tree the gates have not passed. It followed the push of the tag until a red test core on the merge request that carried 10.2.0 sat next to a release page that was already up: the page describes a tag, but what a reader takes from it is that the tree is good, and the only run that answers that is the one on main. It checks out the whole history and every tag, since the page is the commit log between this tag and the one below it in version order, and a shallow clone carries neither.

The tag is the one this commit reaches, not the one pointing at it. A release arrives on main as one merge of develop and the tag sits on the merged commit, so --points-at HEAD would find nothing on every release there has been. The guard asks for v and the version in the plugin.json this build hands out, then whether the commit reaches it, and it says which answer it got in the run summary the same way the publish guards do. Almost every answer is that the tag already has a page, which is the shape of "nothing to do" here.

A hand dispatch is the way to write a page again, and it is also the way through the gap this event carries: workflow_run fires when Arena main finishes, so a tag pushed after that run is seen by nothing. Pushing main with --follow-tags, which versioning_steps.md is written around, is what keeps the tag there before the run that describes it.

../../scripts/ci/arena/release-notes.ts writes the page. GitHub's own --generate-notes is what it replaces, and the reason is the shape of this history: every release arrives on main as one merge of develop, so the generated page would say Merge pull request #19 and stop. What carries a release here is the commit subjects, which are written as a sentence naming the defect, so the script groups them by the area each one names and prints them as they were written. An em dash becomes a comma on the way out, because prose here punctuates without one and a release page is read by more people than any document in the tree.

A page that exists is edited rather than replaced, so the dispatch repairs a release whose notes were written before a subject was corrected, and the assets uploaded to it afterwards stay where they are. That input is also how the releases older than this workflow got their pages.

It names no CHROME_PATH. check:portability fails when more than one workflow does, and Arena main is the one that may.

Why are the published package versions not identical?

Because a package is published only when something it carries has changed.

Arena's version lives in one place, .claude-plugin/plugin.json, and every package is stamped from it at assembly. They are never hand-versioned, so a published package can never disagree with the tag it was cut from. What differs is not the number but which numbers exist.

Suppose the two layer packages are published at the version Arena currently carries. The next release changes the React layer and nothing in Angular: @dravensoft/arena-react is published at the new version, and @dravensoft/arena-angular keeps the one it has, because republishing it would ship an identical tree under a new number. The release after that touches Angular, so it goes to the version current by then and skips the one in between, which never existed for that package.

So the newest version of a package is the version of the last Arena release that changed it. A gap in the sequence is the record of a release that left it alone, and two packages at different versions are two packages that last changed at different times. All of them are always built from the same tree.

A job that runs a script installs, or the script imports nothing outside the tree

Either half satisfies it. Bun answers a bare specifier in a job with no bun install by fetching it at whatever major the registry serves rather than the one bun.lock pins, so a script that reaches one is a script whose behaviour is decided elsewhere and can change with no commit here. The guard jobs of the three publish workflows, the routing job of pr.yml and the whole of release.yml deliberately skip the install, because what each asks costs less than it would; their scripts therefore import only node: builtins and files in this tree. check:workflow-scripts reads a job rather than a workflow and refuses the other combination.

Notes on the runner

Every action here runs on node24. That is the runtime GitHub executes the action's own code in, declared as runs.using in its action.yml, and the only other value is node20. A node20 action is warned about on every run, forced onto node24 anyway, and will eventually be refused, so an action's major is bumped when the new one moves the runtime rather than when it merely exists. It is unrelated to node-version in the publish workflows, which is the Node those jobs install to run npm. Versions live in these .yml files and nowhere else, and nothing generates them, so the whole rule is grep -n 'uses:' .github/workflows/*.yml against each major's runs.using.

Two of those majors carry a guard worth reading before the next bump. actions/checkout from v7 refuses to check out fork pull request code under pull_request_target or workflow_run, which the publish workflows escape only because the run they follow is a push to main; a workflow_run whose upstream event began with pull_request would need allow-unsafe-pr-checkout, which is a question to answer rather than a flag to set. actions/setup-node from v6 caches automatically only when packageManager names npm, and this repository's names bun, so a jump to v5 rather than past it would have switched on a cache nobody asked for.

Chromium. The gates that measure a real render drive a browser, and CHROME_PATH is terminal: set and pointing at nothing, they report that rather than falling back to the candidate list. Only main.yml sets it, to /usr/bin/google-chrome, which the image documents. pr.yml and develop.yml name no browser on purpose, so every pull request proves the candidate list finds one, and the single workflow that does name one keeps the terminal-override branch exercised. Exporting it everywhere is what made that list unreachable in the first place, and a runner is the last place that should be re-established.

Strictness is automatic. GitHub sets CI=true, which skipExitCode() reads, so a gate whose dependency is missing fails instead of skipping. There is nothing to configure and nothing to remember; a missing browser is a red run.

The cache is not an artifact. actions/cache carries a build to the jobs that need it and nothing else, and it does that twice, with no restore-keys either time. Inside Arena PR the key is the run and its attempt, so it is never stale, and a restore that misses fails the job rather than testing an unbuilt tree. From Arena main to the four that follow it the key is the commit, because the reader is a separate run, and a restore that misses builds instead, because there is a real commit whose build has simply aged out. Both keys are exact for the same reason: a prefix fallback would hand a job somebody else's build.

upload-artifact appears only in the three publish workflows, where the artifact is a release record rather than a hand-off. The two never trade places. A cache is evicted at seven days unread and is addressed by a key nobody keeps; an artifact is kept for ninety days and is the account of what shipped.

check:docs reads this directory. Every .md here is held to the size limit and to the punctuation rule, the same as anywhere else in the tree. It does not read .yml: nothing does, so the workflows themselves are held only by GitHub.