Shared GitHub Actions workflows and composite actions for the kanso-labs
organization.
Every repository here is public, so nothing needs to be configured for one of
them to consume these. A private consumer would need
Settings → Actions → General → Access opened up first.
| Thing | Kind | Solves |
|---|---|---|
actions/lint-workflows |
Composite action | Running actionlint, pinned, in every repository that has workflows |
actions/setup-node |
Composite action | The Node setup preamble repeated in every Node CI job |
_publish-npm.yaml |
Reusable workflow | Publishing a package to npm and to GitHub Packages, after a release is cut |
_release-please.yaml |
Reusable workflow | Proposing releases, with a token whose pull requests run CI |
_renovate-command.yaml |
Reusable workflow | Answering @renovate rebase on a pull request, the way Dependabot does |
Pin an exact version. Renovate opens the bump pull requests, the same way it does for every other action these repositories pin.
- name: Set up Node
uses: kanso-labs/github-actions/actions/setup-node@v2.0.0concurrency:
cancel-in-progress: false
group: release-please
jobs:
release-please:
name: Propose releases
uses: kanso-labs/github-actions/.github/workflows/_release-please.yaml@v2.0.0
secrets:
client-id: ${{ secrets.RELEASE_PLEASE_CLIENT_ID }}
private-key: ${{ secrets.RELEASE_PLEASE_PRIVATE_KEY }}That concurrency block is not optional — see below.
Tracking @main instead would mean a mistake here breaks CI in every consuming
repository at once, with no way to hold one back. That is the whole reason for
the tags.
Reads the Node version from .tool-versions, restores the npm cache, and runs
npm ci. Full inputs are in its README.
The npm cache comes from actions/setup-node's own cache: npm, which is why
consumers do not need a separate actions/cache step for ~/.npm. Any such
step left behind is doing nothing.
Runs actionlint over .github/workflows, at a pinned version. Full inputs are
in its README.
Put it in the job a ruleset already requires rather than in a job of its own. A new job means a new check name, and a check name nothing requires can fail without stopping anything — so a workflow linter added that way reports problems to no one.
A repository silences a rule by committing .github/actionlint.yaml, which
actionlint discovers by itself. That file is also the escape hatch for
actionlint being stale: it carries its own copy of the valid permission scopes,
so a scope GitHub has added since the pinned release reads as an error on a
workflow that is perfectly correct. A consumer uploading code coverage needs
exactly that for code-quality, which actions/upload-code-coverage requires.
Publishes a package once _release-please.yaml has cut a release — to npmjs.com
over trusted publishing, then to GitHub Packages. One call does both.
publish:
name: Publish to npm
needs: release-please
if: needs.release-please.outputs.release_created == 'true'
permissions:
contents: read
id-token: write
packages: write
uses: kanso-labs/github-actions/.github/workflows/_publish-npm.yaml@v3.0.0Compare release_created against the string. A bare truthiness test also passes
on "false", which is what that output carries when release-please runs and
decides not to cut a release — so the package would be published on every merge.
Publish to npm runs first and Publish to GitHub Packages needs: it.
npmjs.com is the source of truth for what a version of a package is; GitHub
Packages carries a copy. The edge between the jobs is what keeps the copy from
getting ahead of the original: if the public publish fails, both registries stay
a version behind, and the next release — or a re-run of the failed job on its
own — catches them up together. Drop the edge and a failed npm publish leaves
GitHub Packages serving a version npmjs.com has never heard of, which is the one
state a version number cannot be reasoned about from.
The second job repeats the checkout, install and build rather than sharing the first job's. The two differ in the single thing a job cannot parameterise — its permissions — and the next section is why that difference has to be kept.
npmjs.com goes over trusted publishing, so no secret is passed for it at all.
id-token: write is what npm exchanges for a short-lived credential, and what
mints the provenance attestation alongside it.
GitHub Packages has no trusted publishing. It takes GITHUB_TOKEN with
packages: write, set as NODE_AUTH_TOKEN on the publish step alone rather
than on the job. Nothing attested comes out of that half: provenance is minted
during the OIDC exchange, so the tarball is attested at npmjs.com and only
mirrored here.
Each job declares its own scope and neither holds the other's, which is
load-bearing rather than tidy. npm decides whether to attempt an OIDC exchange
by looking for the variables id-token: write injects, and GitHub Packages has
no trusted publisher to exchange against — so a file-level block granting both
scopes would invite npm to try something that registry cannot answer, in the
publish half of a release that has already been tagged.
The caller grants all three together. It is checked against each job separately, so a scope left out still fails the run at once with that scope named, rather than being refused by a registry for a reason that reads as a registry problem.
The first publish of a package to npmjs.com cannot use this. npm configures
a trusted publisher on the package page, and the package has to exist before
there is a page to configure, so a new package is pushed by hand once and this
takes over afterwards. Both packages in this organization were bootstrapped that
way. GitHub Packages needs no equivalent: GITHUB_TOKEN creates the package on
first publish, and it arrives public.
Worth stating because the opposite is widely reported, and this section said it too until the first two releases proved otherwise.
Both packages were created by their first release through this workflow and both
came out visibility=public immediately, with no manual step:
$ gh api "/orgs/kanso-labs/packages?package_type=npm"
<package> visibility=public
<package> visibility=public
What makes that so is not isolated here, so do not over-generalize it. These are
public repositories, the packages are scoped to the organization that owns them,
and publishConfig.access is public in both manifests — which is what npm's
--access reads. A private repository, or a manifest without that key, is a
case this organization has no evidence about.
The check is still worth a glance after the first release that creates a package somewhere new, because visibility is a package-level setting and nothing in the run reports it. It is under the organization's Packages tab. Making a package public is one-way if it ever does come out private — a public package cannot be made private again.
Set up Node in Publish to GitHub Packages names registry-url as the
public registry, which reads like a copy-paste slip. It is not one.
Handed a GitHub Packages URL, actions/setup-node writes a scoped registry
line — @kanso-labs:registry=https://npm.pkg.github.com/ — because that
registry serves exactly one scope. That line governs installs as much as
publishes. A consumer that devDepends on another @kanso-labs package, pinned
to a version that predates this job, would therefore run an npm ci that goes
looking for that version on a registry that will never carry it, 404, and
half-land the release: tagged, on npm, missing from the mirror.
So that job installs and builds against the public registry, then calls
actions/setup-node a second time to repoint npm once nothing is left to
resolve. The second call appends rather than replaces — the action drops only
lines beginning with the registry key it is about to write, and a scoped key
does not match the unscoped registry= line already in the file.
Writing that .npmrc line by hand instead would mean finding the file through
NPM_CONFIG_USERCONFIG, which is an internal of the action. Calling the action
twice asks the thing that owns the file to rewrite it.
A caller granting fewer scopes than a called job requests fails the run
outright. So packages: write has to be on the caller before its pin moves
to a version of this repository that has the second job, not in the same change:
- Merge
packages: writeinto the consumer's publish job. Harmless while the pin is still onv2.x— a caller may grant more than a called workflow asks for, and the unused scope goes nowhere. - Let Renovate bump the pin. That merge is what turns the second job on.
Doing it the other way round breaks the publish half of whatever release lands first.
The composite action defaults it to false and this defaults it to true,
which is worth explaining rather than lining up.
npm runs prepare on publish as well as on install. A repository whose
prepare downloads Playwright's browsers therefore pays for that download twice
in each job — once installing, once as a step of the publish itself, where
failing to fetch a browser aborts a release that has already been tagged.
Nothing either package ships is produced by a lifecycle script; the build step
is the whole of it.
Set it to false only for a package with a native dependency to compile.
| Input | Default | Description |
|---|---|---|
build-script |
build |
npm script producing what is published |
dry-run |
false |
Publish with --dry-run, uploading nothing |
github-registry-url |
https://npm.pkg.github.com |
GitHub Packages registry the second job publishes to |
ignore-scripts |
true |
Pass --ignore-scripts to npm ci and publish |
node-version-file |
.tool-versions |
File the Node version is read from |
registry-url |
https://registry.npmjs.org |
Public registry to publish to, and to install from |
Pass build-script: '' for a package with nothing to build; the step is then
skipped rather than running npm run with an empty argument.
There is no input for the GitHub Packages scope. That registry accepts only
the owning organization's, so it is always github.repository_owner — which,
inside a called workflow, is the caller's owner and not this repository's.
Deliberately, and it is the one duplication here that should stay.
A ./ reference inside a reusable workflow resolves against the checkout in the
workspace, and that checkout belongs to the caller — so it would look for the
composite in the consuming repository and not find it. GitHub documents ./ for
referencing a workflow in the same repository and says nothing either way about
an action referenced from inside a called workflow, which is not a thing to rest
on when every consumer publishes through here.
Naming the composite by tag would work and introduces a worse problem: it pins this repository to a version of itself that does not exist until the release carrying the change is cut.
What inlining gives up is the cache handling, and both jobs turn that off regardless — each installs once and throws the runner away, so restoring the cache costs more than the single install it would save.
dry-run runs everything including npm publish --dry-run, in both jobs, which
resolves the manifest and prints the file list without uploading. It cannot be
exercised in this repository: package.json here is private: true, and npm
refuses to publish — even a dry run — for a private package.
Canary it in a consumer instead, following the recipe in
AGENTS.md. Pick carefully: a consumer with no @kanso-labs
dependency of its own will not exercise the scoped-registry trap above, and only
one that installs another kanso-labs package can.
Wraps googleapis/release-please-action with the things that were only ever
configured in one repository:
An application token, when the caller supplies one. Pull requests opened
with the default GITHUB_TOKEN do not start workflow runs. Their checks sit at
action_required until somebody approves them by hand, so a release stalls
there — and if the repository requires status checks to merge, auto-merge can
never fire, because the checks it is waiting for never report. Requests made
with a GitHub App token trigger checks like any other.
Both client-id and private-key are optional. Without them the run falls back
to GITHUB_TOKEN, warns in the job log, and still opens a correct release pull
request — it just has to be merged by a person. That fallback is what lets a
repository adopt this workflow before its secrets are in place.
There is no app-id input. Both workflows took one as a deprecated alias
through v1.x and dropped it in v2.0.0, which is what makes v2 a major. A caller
bumping to v2 has to rename the secret it passes; the value need not change,
since a GitHub App id and a client id are interchangeable where it lands — each
is accepted as the JWT issuer. Passing an app id under the client-id name
still works, so the rename can be done before the secret is.
In fallback mode, auto-merge is disabled no matter what auto-merge is set
to. Merging the release pull request is only half of a release: that merge
pushes the default branch, and the run it starts is what cuts the tag and the
GitHub release. A push made with GITHUB_TOKEN starts no run, so auto-merging
as it leaves the release half-applied: the version is bumped and the changelog
written, but nothing is tagged. The next push to the default branch, whenever
that happens and whatever it is for, cuts the tag late — so on a quiet
repository the released version spends an unbounded stretch being one that
nobody can pin. A person's merge starts the run immediately, so the fallback
leaves the merge to them.
A repository whose ruleset requires status checks cannot really use the fallback at all: the release pull request never starts the checks it is required to pass, so nobody without bypass can merge it. That is the real reason such a consumer needs the application installed, rather than a stylistic one.
Auto-merge on the release pull requests. Enabled by default; pass
auto-merge: false to turn it off. Note that --auto only queues when
something is already blocking the pull request. With no required checks it
merges on the spot.
The workflow deliberately declares no permissions block, because what the
caller needs depends on which token it uses:
| Caller supplies | Needs at workflow level |
|---|---|
client-id and private-key |
contents: read — the app token does the writing |
| Neither | contents: write, issues: write, pull-requests: write |
A called workflow cannot request more than its caller granted, so declaring
write here would force the first kind to widen its GITHUB_TOKEN for no reason.
Overlapping release-please runs race each other. Every release merge pushes the
default branch, every push starts a run, and two runs landing together try to
cut the same tags and strip the same labels — one then dies on the other's
half-finished work. cancel-in-progress stays off, because the last push must
still get a run or the release it carries is never proposed.
The guard is required, and it goes in the caller. GitHub documents concurrency
at the caller and says nothing either way about a group declared inside a called
workflow, and a guard that only matters during a rare race is the worst thing to
rest on undocumented behaviour: if it quietly did nothing, every consumer would
lose it at once and nobody would learn that until the race happened. In the
caller it is supported, it is visible in review, and forgetting it costs one
repository instead of all of them.
Runs release-please without opening a pull request or cutting a release. It
exists so this workflow can be tested on a pull request, and
Dry run release-please in test.yaml is its only caller. A repository
releasing for real has no reason to set it.
release_created, releases_created, paths_released, prs, tag_name and
version, passed straight through from the action. A caller that publishes on
release reads release_created:
publish:
needs: release-please
if: needs.release-please.outputs.release_created == 'true'Renovate has no comment commands. Asking it to redo a pull request means ticking
a checkbox in the body and then waiting for its next scheduled run, which in
this organization is up to three hours away. This workflow closes both halves of
that gap, so @renovate rebase behaves the way @dependabot rebase does.
on:
issue_comment:
types:
- created
concurrency:
cancel-in-progress: false
group: renovate-command-${{ github.event.issue.number }}
permissions: {}
jobs:
renovate-command:
name: Run the command
uses: kanso-labs/github-actions/.github/workflows/_renovate-command.yaml@v2.0.0
secrets:
client-id: ${{ secrets.RENOVATE_CLIENT_ID }}
private-key: ${{ secrets.RENOVATE_APP_PRIVATE_KEY }}| Comment | Effect |
|---|---|
@renovate rebase, @renovate retry, @renovate recreate |
Ticks the pull request's rebase checkbox, then dispatches the runner |
@renovate run |
Dispatches the runner without touching the pull request |
The three pull request commands are one command under three names, because which name is right depends on what Renovate decides to do with the branch, and nobody asking for it knows that yet. Renovate's own checkbox says "rebase/retry", and it recreates the branch when the rebase is not enough.
The bot reacts as it goes: 👀 accepted, 🚀 dispatched, 😕 refused. A refusal is never a failed run, so somebody's typo does not put a red X on the pull request.
There is no API for "rebase this Renovate branch", and the workflow does not try
to rebase anything itself. It edits the pull request body, turning
- [ ] <!-- rebase-check --> into - [x] <!-- rebase-check --> — byte for byte
what GitHub records when a person clicks that checkbox — and then dispatches the
runner so Renovate reads it now instead of in three hours.
Everything that decides what "rebase" means stays inside Renovate. That is the point: the workflow adds a way to ask, not a second implementation of the answer.
It follows that the commands only work on pull requests Renovate opened. On anything else the marker is absent and the run fails loudly, which is the honest outcome — the alternative is a 👍 on a request nothing will ever act on.
Unlike _release-please.yaml, there is no GITHUB_TOKEN fallback, because
GITHUB_TOKEN cannot dispatch a workflow in another repository under any
permissions. The app must be installed on the calling repository and on the
runner, and needs actions: write there — the permission nothing else in this
organization uses.
The caller grants no permissions at all. Every write goes through the app token,
so this workflow can and does declare permissions: {}, which is the opposite
of _release-please.yaml above and for a reason worth keeping straight: a
called workflow may always request less than its caller granted. It is
requesting more that fails the run.
issue_comment is a repository-level event. GitHub runs the copy of the
workflow that is on the default branch and ignores every other copy, so a caller
sitting on a branch does nothing no matter how many comments it gets — and
neither does a change to one.
A repository adopting this therefore merges the caller first and finds out
whether it works second. Point the uses: ref at a branch of this repository
for that first merge if the workflow itself is what is being tried out, then
move it to a tag.
The comment author must have write or admin on the repository the comment is
in. The check asks GitHub for the permission level rather than reading
author_association, which is free but reports NONE for a member whose
organization membership is private — silently ignoring the person most likely to
be using this.
The mention must also open a line. GitHub prefixes a quoted reply with > , so
quoting a command repeats it without running it.
release-please owns version.txt and .release-please-manifest.json. Nobody
edits either by hand.
The type of the pull request title decides what happens, and because pull
requests are squash-merged, that title is the only commit that reaches main:
| Title type | Effect |
|---|---|
feat |
Minor release |
fix |
Patch release |
deps |
Patch release |
ci |
Patch release |
| anything else | No release |
deps is not a Conventional Commits type. It exists because Renovate's default
type is chore(deps), and chore is hidden in release-please's defaults —
release-please decides there are no user-facing commits and opens no release
pull request at all. So a run of nothing but dependency upgrades released
nothing, and an upgrade shipped only when a feature happened to land beside it.
Consumers pin exact tags, so an upgrade that cuts no release is one nobody can
pin. .github/renovate.json therefore sets semanticCommitType: deps, and
release-please-config.json gives that type a visible Dependencies section.
The section list there replaces release-please's defaults wholesale rather than
extending them, so dropping feat or fix from it would silently stop those
releases too.
ci is a Conventional Commits type, and it is visible here for the same reason
deps is. The workflows and composite actions in this repository are the thing
consumers pin, so a ci: commit is usually a change to the product rather than
to the scaffolding around it — and hidden, it shipped only when something else
happened to release beside it.
The type is coarser than the distinction it is standing in for. This
repository's own lint.yaml, test.yaml and release-please.yaml are consumed
by nobody, so a ci: change to one of them now cuts a release that says nothing
to any consumer. release-please keys on the commit type and not the path, so
that is the cost of the type being visible at all. Reach for chore: when a
change genuinely touches only this repository's own scaffolding.
A plain chore: still publishes nothing, which is the point: housekeeping
should not cut a release.
A release that changes how a consumer must call something is a breaking change,
and it needs ! so the major moves. Consumers pin exact versions, so nothing
breaks the moment it is released — but a Renovate bump landing on a repository
that never read the changelog is the same failure a day later.
This repository's own release-please.yaml references ./ rather than pinning
a tag, which is the one place the advice above is inverted. A pin makes the
repository a consumer of itself: Renovate bumps it, the bump cuts a patch
release as any deps change does, the release moves the tag, and the next
Renovate run bumps it again — forever. Consumers have no such cycle, because a
release of this repository does not move any tag they pin.