Reusable GitHub Actions workflows for cross-cutting CI across every repo under this account. One source of truth for Claude Code review, browser- extension publishing, and anything else that recurs.
Reusable workflow that installs dependencies and runs lint,
typecheck, test, and (opt-in) build package.json scripts —
skipping any that don't exist with a notice. Auto-detects the package
manager from the lockfile (pnpm > yarn > bun > npm) and supports an
explicit override. Defaults to Node 24.
It also runs a dependency vulnerability audit (npm audit / pnpm audit / yarn audit / bun audit, matched to the detected package
manager) — on by default, failing at audit-level: high or worse,
so anything gated on this job via needs: (a deploy, a publish) won't
ship on a high/critical advisory. Tune with with: audit-level: …
(low/moderate/high/critical) or turn it off with with: run-audit: false.
Pass with: run-build: true for projects (e.g. browser extensions)
where the build itself is the most useful PR-time check; that way the
PR-time tests.yml shim subsumes what a separate build.yml would
have done.
Optionally pass secrets: NPM_TOKEN to authenticate to
registry.npmjs.org for both the install and the audit — see
Registry authentication.
Reusable workflow for ruff lint, ruff format check, and pytest. Defaults to Python 3.14.
Reusable workflow for releasing browser extensions to the Chrome Web
Store. Handles 429 + 5xx retries (with Retry-After honored), parses
uploadState and status[] body fields (HTTP 200 + uploadState: FAILURE is a real silent-failure mode in naive code), staggers parallel
releases deterministically by repo hash, and skip-with-warning when
secrets are missing.
Same shape as Chrome but for AMO. Honors the actual Retry-After HTTP
header (the older .retry_after JSON-body pattern is wrong), uses the
multipart-split pattern for release_notes (AMO rejects translatable
fields combined with multipart source uploads), and the same
deterministic stagger.
Reusable workflow that merges a Dependabot PR after Claude (or any trusted reviewer) approves it. Two modes:
mode: auto(default) — callsgh pr merge --auto. Pair with apull_request_reviewtrigger in your shim. Requires GitHub's auto-merge feature (Pro+ for private repos, Free only for public).mode: direct— callsgh pr mergeimmediately after CI succeeds. Pair with aworkflow_runtrigger watching your CI workflow. For Free private repos where--autoisn't available (seegithub-private-repo-auto-merge-workaroundskill).
Auto-detects the merge method from the repo's allowed methods (priority:
squash > rebase > merge); override via merge-method:.
For the cleanest gate (review skipped if tests/build/CodeQL fail, and
in-flight reviews cancelled when the PR closes/merges), each caller
combines node-test.yml + claude-code-review.yml into one workflow
with needs: ordering, the closed trigger, and per-job if: gates:
name: PR checks
on:
pull_request:
# `closed` is here so a merge fires a same-concurrency-group run that
# cancels the in-flight one (cancel-in-progress: true). The new run
# itself does no work — both jobs skip via the if: gate below.
types: [opened, synchronize, reopened, closed]
paths-ignore:
- '.github/workflows/pr-checks.yml'
permissions: { contents: read }
concurrency:
group: pr-checks-${{ github.ref }}
cancel-in-progress: true
jobs:
tests:
if: github.event.action != 'closed'
permissions: { contents: read }
uses: dustfeather/shared-workflows/.github/workflows/node-test.yml@v4
with:
run-build: true # extension repos / projects with a meaningful build script
review:
if: github.event.action != 'closed'
needs: tests # review skips if tests fail
permissions:
contents: read
pull-requests: write
issues: read
id-token: write
uses: dustfeather/shared-workflows/.github/workflows/claude-code-review.yml@v4
secrets: inherit # use explicit pass for cross-owner — see "Cross-owner callers"needs: tests gates review on tests/build passing. The central review
workflow itself adds a cheap bash gate that skips when CodeQL has
already failed (no agent spin-up, no Claude tokens) — treating
IN_PROGRESS as "proceed" so reviews aren't blocked waiting for CodeQL.
Adding closed to the trigger types + the if: != 'closed' gates on
each job means a PR being merged or closed mid-review cancels the
in-flight run via the shared concurrency group — no Claude tokens
spent on a review whose PR is no longer open.
Runs Claude Code Action on every PR (after a trusted-actor / allowed-bot gate). Uses a structured prompt that splits dependabot bumps from human PRs:
- Dependabot track: classifies the bump (patch/minor/major), checks the
changelog for breaking changes, approves or requests changes via
gh pr reviewbased on the rules. - Standard track: leaves inline comments + a top-level summary; never auto-approves human PRs.
Picks up @claude mentions in issues, issue comments, PR review comments,
and PR reviews from a trusted actor and hands the conversation to Claude
Code Action.
Command-shaped rather than framework-shaped: it takes shell strings
(build-command, pre-deploy-command, deploy-command) instead of detecting
whether the caller is a plain Worker, an OpenNext Next.js app or something
else. One job deploys one Worker; a repo with two Workers calls it twice and
orders them with needs:.
The step order is fixed and is the part that matters:
install → build → pre-deploy → budget gate → deploy
→ secrets check → startup gate → cron check → verify
pre-deploy-command (migrations, publishing reference data) runs before the
deploy on purpose. A Worker whose schema or KV is not there yet does not fail
to start — it answers wrongly, which reads as an application bug rather than a
deploy that ran out of order.
Two budget gates, and they sit on opposite sides of the deploy because wrangler reports the two numbers at different times:
| Gate | Input | When | Prevents a bad deploy? |
|---|---|---|---|
| Bundle gzip size | max-gzip-kib |
before, via --dry-run |
yes |
| Worker startup time | max-startup-ms |
after the upload | no — only the upload measures it |
The startup gate cannot block the version that tripped it, because
--dry-run never reports a startup time. What it does is make the next
merge fail loudly instead of letting cold-start creep run until it hits the
hard 1 s ceiling and deploys start dying with
10021 Script startup exceeded CPU time limit. Both gates default to 0,
which disables them.
verify-url polls the deployed Worker until it answers 2xx (12 attempts, 5 s
apart by default). Skipping it is allowed and is a choice to deploy without
checking.
Secrets CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID are both required.
A hostname fronted by an Access application answers an unauthenticated probe
with a 302 to <team>.cloudflareaccess.com, never a 2xx. verify-url then
burns every attempt and fails a deploy that actually succeeded, taking the
downstream jobs with it.
Pass an Access service token and the verify step sends it as the
CF-Access-Client-Id / CF-Access-Client-Secret headers:
deploy-api:
uses: dustfeather/shared-workflows/.github/workflows/deploy-cloudflare.yml@v4
with:
working-dir: apps/api
verify-url: https://gated.example.com/api/v1/health
verify-requires-access: true
secrets:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CF_ACCESS_CLIENT_ID: ${{ secrets.CF_ACCESS_CLIENT_ID }}
CF_ACCESS_CLIENT_SECRET: ${{ secrets.CF_ACCESS_CLIENT_SECRET }}- The token must be attached to a
non_identitypolicy on that application. Access ignores a service token that no policy admits and keeps redirecting. - Both halves must be non-empty or neither is sent. Omitting them keeps the prior behaviour exactly: an unauthenticated probe.
verify-requires-access: trueturns the silent fallback into a failure. An unset or misnamed secret interpolates to the empty string, so without this flag a typo in the caller'ssecrets:block looks identical to "this caller has no Access in front of it" — and the run fails much later, claiming the Worker is not serving.- When the probe still lands on
cloudflareaccess.com, the error says whether a token was sent (Access rejected it) or not (the URL is gated and no token was supplied) instead of blaming the Worker. - Headers travel through the step's positional parameters; the values are never interpolated into a command string and never echoed.
A Worker whose only entrypoint is scheduled() has no HTTP surface, so
verify-url has nothing to curl and every other gate in the workflow runs
before or beside the schedules rather than on them. Wrangler makes that gap
easy to fall into:
- an absent
triggersblock is a no-op, not an error — the crons already deployed are left in place, so a block under the wrong environment key, or in a config file wrangler never read, uploads clean with zero schedules and zero warnings; wrangler versions uploaddoes not apply triggers at all. A caller that overridesdeploy-commandwith it gets its secrets and no crons, and needs a follow-upwrangler triggers deploy.
Either way the deploy is green and the failure only shows up an hour or a day later as "the job never ran", with no failed run to point at.
expect-crons closes that. Set it to the comma-separated expressions the deploy
must leave registered, and after the upload the job reads
GET /accounts/{id}/workers/scripts/{name}/schedules and fails naming any that
are missing. It asserts the end state on the account rather than parsing
wrangler's output, so it also covers the versions upload path — where the log
would legitimately never mention a schedule. No new credential: the endpoint
takes Workers Scripts Read, which CLOUDFLARE_API_TOKEN already exceeds by
being able to deploy.
Empty (the default) skips the check, so it is non-breaking for existing callers.
The script name is resolved from the deploy output, then from name in the
wrangler config; worker-name overrides it if both fail.
deploy-cron-worker:
uses: dustfeather/shared-workflows/.github/workflows/deploy-cloudflare.yml@v4
with:
working-dir: workers/gw2roi
expect-crons: 0 * * * *
secrets:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}Optional. Publishes the caller's GitHub repo secrets onto the Worker as part of the deploy, so the credentials a repo holds and the credentials its Worker runs with cannot drift apart.
They travel via wrangler's --secrets-file, which attaches them to the
version being deployed — not as a follow-up wrangler secret bulk. A bulk
call after the deploy would publish a second version, leaving a window where
the new code runs against the old credentials, and it requires the Worker to
already exist, which is false on a first-ever deploy.
- Additive. Dropping a key from
WORKER_SECRETSdoes not delete it from the Worker. Removing a secret is still a deliberatewrangler secret delete. KEY=VALUElines are the form to use. Wrangler tries JSON first and falls back to dotenv; the JSON form makes the caller responsible for escaping every value. Values must be single-line in either form.- Names only are logged, and the staged file is shredded after the deploy.
- An unparseable payload fails the job. Wrangler silently no-ops on input it
cannot read, which would otherwise deploy a Worker with no secrets and report
success. After deploying, the job re-reads
wrangler secret listand fails if any staged name is absent.
deploy-api:
needs: test
uses: dustfeather/shared-workflows/.github/workflows/deploy-cloudflare.yml@v4
with:
working-dir: apps/api
install-dir: .
runner: arc-df-my-repo
pre-deploy-command: npx wrangler d1 migrations apply my-db --remote
max-gzip-kib: 600
max-startup-ms: 400
verify-url: https://example.com/api/v1/health
secrets:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
WORKER_SECRETS: |
SESSION_SECRET=${{ secrets.SESSION_SECRET }}
STRIPE_SECRET_KEY=${{ secrets.STRIPE_SECRET_KEY }}The sibling of deploy-cloudflare.yml, and command-shaped for the same
reason: the callers it was derived from deploy a Deployment, a StatefulSet, a
CronJob and a bare Service. There is no single shape to detect, so it takes
manifest paths and shell strings.
The step order is fixed:
build+push → namespace → pull secret → app secrets → prune
→ apply → rollout → verify → show
Secrets are materialised before the apply on purpose. A Deployment whose
Secret does not exist yet does not fail the apply — the pod sits in
CreateContainerConfigError and rollout status times out with no hint why,
which reads as a broken image rather than a missing key. The ghcr pull Secret
comes first for the same reason: without it the pod lands in ImagePullBackOff
and the rollout times out identically.
Auth is the runner pod's own ServiceAccount. There is no KUBECONFIG
input. Every caller runs on an ARC scale set inside the target cluster, where
kubectl picks the token up with no configuration — so runner: must name a
scale set in that cluster. A GitHub-hosted runner cannot use this workflow.
This workflow does not build images, and that is a constraint rather than
an omission. A called workflow may not request more GITHUB_TOKEN permission
than the calling job grants, and permissions: accepts no expressions — so a
workflow asking for packages: write in order to build would break every
caller that grants least privilege, whether or not that caller builds.
The break is also unusually nasty: the run dies at validation with
startup_failure, zero jobs and no logs. GitHub's explanation ("the nested
job is requesting packages: write, but is only allowed packages: none")
appears in the web UI only and is absent from the REST and GraphQL APIs, so
gh run view --log-failed returns nothing at all.
So building lives in build-push-image.yml. A caller that needs one runs it
first and passes the image through:
build:
permissions:
contents: read
packages: write # only this job needs it
uses: dustfeather/shared-workflows/.github/workflows/build-push-image.yml@v4
deploy:
needs: build
permissions:
contents: read # deploy never needs more
uses: dustfeather/shared-workflows/.github/workflows/deploy-k8s.yml@v4
with:
image: ${{ needs.build.outputs.image }}Every input defaults to behaviour-preserving, so a minimal call is namespace + manifests. The ones worth knowing:
| Input | Default | Note |
|---|---|---|
namespace |
required | every kubectl call is -n scoped to it |
ensure-namespace |
get |
get asserts, create creates, none skips. get is the default because a per-scale-set runner SA usually cannot create namespaces at all, and create would trade a clear error for an RBAC denial |
setup-kubectl |
false |
the pre-baked runner image already ships kubectl and envsubst; turn on for a scale set still on the stock actions-runner image |
image |
derived | full reference exposed to templated-manifests as the image variable. Empty derives ghcr.io/<owner>/<repo>:<sha>; pass build-push-image.yml's output when you built one |
manifests |
"" |
applied verbatim, in order |
templated-manifests |
"" |
piped through envsubst first, applied after manifests |
envsubst-vars |
image var only | restricts substitution. Unrestricted envsubst also eats shell-looking tokens that belong to the manifest — a snippet in an exec probe, an arg the container expects at runtime — silently replacing them with an empty string |
rollout-targets |
"" |
empty skips the wait; a CronJob-only deploy has no rollout to watch |
verify-command |
"" |
non-zero exit fails the deploy |
show-command |
kubectl get pods,svc |
runs with if: always() |
show-command running under always() is not cosmetic. Without it the
diagnostics step is skipped exactly when the rollout failed, so the log holds
the timeout and nothing that explains it. The step also dumps recent namespace
events, which is where ImagePullBackOff and missing-Secret failures actually
show up. One such failure took commit archaeology to reconstruct because the
caller's own Show result step had no always().
Build-and-deploy caller — two jobs, and only the first is privileged:
build:
needs: test
permissions:
contents: read
packages: write
uses: dustfeather/shared-workflows/.github/workflows/build-push-image.yml@v4
with:
runner: arc-itguys-ro-apps-page
deploy:
needs: build
permissions:
contents: read
uses: dustfeather/shared-workflows/.github/workflows/deploy-k8s.yml@v4
with:
namespace: apps-page
runner: arc-itguys-ro-apps-page
setup-kubectl: true
ensure-namespace: none
image: ${{ needs.build.outputs.image }}
manifests: deploy/service.yaml
templated-manifests: deploy/deployment.yaml
rollout-targets: deployment/apps-page
rollout-timeout: 120sNo-build caller with a real post-deploy assertion:
deploy:
uses: dustfeather/shared-workflows/.github/workflows/deploy-k8s.yml@v4
with:
namespace: dosar-rapid-render
runner: arc-df-dosar-rapid
manifests: |
deploy/render/service.yaml
deploy/render/deployment.yaml
rollout-targets: deployment/render
rollout-timeout: 300s
verify-command: kubectl exec deploy/render -- fc-list ":charset=0400" | grep -q .Optional, and the same idea as WORKER_SECRETS: the caller composes
KEY=VALUE lines from its own GitHub secrets, and the workflow upserts them
as one generic Secret named by secret-name. Applied via a client-side dry
run piped into apply, so it is idempotent. Only the key names are ever
logged.
with:
namespace: social-update
secret-name: social-update-secrets
secrets:
SECRET_LITERALS: |
CLAUDE_CODE_OAUTH_TOKEN=${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}GHCR_PULL_PAT is only needed when ghcr-pull-secret is set, i.e. the
namespace pulls a private ghcr image. It must be a classic PAT with
read:packages — a GitHub App installation token cannot be granted access to
a user-owned package and pulls 403 with a token that is otherwise valid.
Making the package public removes the need entirely.
All three secrets must be passed explicitly by cross-owner (ITGuys-RO/*)
callers; secrets: inherit does not reliably carry org-level
selected-visibility secrets across the owner boundary.
For a namespace whose Secrets do not collapse into one. Each Secret is a
[name] header followed by its own KEY=VALUE lines; blank lines and #
comments are ignored, only the key names are logged, and each Secret is
upserted through the same idempotent dry-run-into-apply as SECRET_LITERALS.
with:
namespace: trading
secrets:
SECRETS_MANIFEST: |
[gw2-api-key]
ARENA_NET_KEY=${{ secrets.ARENA_NET_KEY }}
[gw2-postgres-creds]
POSTGRES_USER=gw2
POSTGRES_DB=gw2
POSTGRES_PASSWORD=${{ secrets.PG_PASSWORD }}
PGUSER=gw2
PGDATABASE=gw2
PGPASSWORD=${{ secrets.PG_PASSWORD }}Note the second Secret above mixes a password with the non-secret user and database names that travel with it. That is the point rather than an accident: splitting it so the non-secret half could arrive as an input would hand the workload two Secrets to mount where the manifest expects one.
There is no input naming these Secrets — the presence of the secret is the switch. An input listing them would be a second place for the same list to be written and a chance for the two to disagree, and it would reveal nothing that is not already logged.
An empty value is an error, unless allow-empty-secret-values: true. An
unset GitHub secret interpolates to the empty string, so without that check a
caller who forgot to set one gets a Secret with the right key and an empty
value — the apply succeeds, the pod starts, and the workload authenticates
with "" against whatever it talks to. Every hand-written deploy this
workflow replaced opened with a test -n guard for that reason, and this is
where the guard went. SECRET_LITERALS is deliberately not covered: it has
always permitted empty values, so tightening it would be a breaking change
rather than a new default.
SECRETS_MANIFEST and secret-name are independent; use either, both, or
neither. If a caller composes it entirely from GitHub secrets that are all
unset, the step fails with "no [secret-name] section could be parsed" rather
than silently creating nothing.
A workflow_call hands the caller a job, not a step, so a repo needing
its own steps interleaved with these cannot use it without smuggling them in
as shell strings. That constraint is what the prune-command,
post-apply-command, verify-command and show-command inputs exist for —
and where a caller's extra work does not fit one of those slots, the answer is
a second job in the caller's own repo, not a wider input surface here.
- A deploy with app-specific observability steps — Grafana dashboard and
alert provisioning, deliberately soft-failing — would drag three
single-caller secrets into this workflow's surface. Split it: this workflow
for image + secrets + apply, then a
needs: deployjob in the caller for the rest, keeping those secrets local to the repo that owns them.ITGuys-RO/investis the worked example. - A Helm-based pipeline is a different problem — the unit of change is a
release, not a file. That is
deploy-helm.ymlbelow. - A caller authenticating with a
KUBECONFIGsecret cannot use this workflow at all: auth is the runner pod's ServiceAccount and there is no kubeconfig input. OnlyITGuys-RO/ollama-k3swas ever in that shape, and its namespace is torn down, so it stays un-migrated rather than justifying an unused credential path. Reviving it means adding an optionalKUBECONFIGsecret here — a backwards-compatible minor bump.
Two cases that used to be listed here are now handled: two distinct Secrets
(see SECRETS_MANIFEST above), and Helm (see below).
The Helm sibling of deploy-k8s.yml, and separate from it for a reason worth
stating: a Helm pipeline is not a kubectl apply pipeline with extra flags.
The unit of change is a release rather than a file, the ordering constraints
are Helm's, and the useful pre-flight — helm lint plus helm template piped
into a dry-run apply — has no counterpart in a manifest apply. Folding it into
deploy-k8s.yml would have doubled that workflow's inputs for every caller
that never installs a chart.
Auth, runner requirements and the contents: read permission ceiling are
identical to deploy-k8s.yml. It has no secrets: the caller it was
derived from pre-creates its Secrets in shapes no KEY=VALUE blob can express
— a patch --merge into a different namespace, a --from-file SSH key —
and references them from values by name.
The step order is fixed:
repo add/update → lint → template check → upgrade
→ post-upgrade → rollout → verify → show
Validation and deployment are the same workflow, called twice. A PR check
calls it with upgrade: false and writes nothing; a deploy calls it with the
defaults. That is what stops the gate and the deploy drifting apart — a
validation step that lints a different chart version than the one being
installed is worse than no gate, because it is green.
| Input | Default | Note |
|---|---|---|
namespace, release, chart |
required | chart is <repo>/<chart>, an oci:// reference, or a directory in the checkout |
chart-version |
"" |
empty resolves to whatever the repository serves today — pin it. Warns unless chart is a directory, which the commit already pins |
repo-url |
"" |
added and updated before resolving chart; repo-name defaults to chart's first path segment |
values-files |
"" |
newline-separated -f paths relative to working-dir, later files winning, checked for existence here so a working-dir mistake names itself |
set-literals |
"" |
--set-string lines, non-secret only — they land in the run log and in helm get values |
setup-helm |
true |
on by default, unlike setup-kubectl: the pre-baked runner image ships kubectl but not helm |
lint |
false |
a remote chart is pulled and untarred first, because helm v4 dropped helm lint --version on a remote reference |
template-check |
false |
render + kubectl apply --dry-run=client. Not offline — see below |
upgrade |
true |
set false for a pure validation gate |
atomic |
false |
rolls back on failure. Implies --wait, so helm-timeout becomes the readiness budget |
rollout-targets |
"" |
separate from atomic because --atomic already waited |
create-namespace |
false |
off because a per-scale-set SA usually cannot create namespaces, and on would trade a clear "not found" for an RBAC denial |
template-check is not an offline parse. kubectl apply --dry-run=client still GETs the live object to compute the merge patch, so it
needs a reachable API server and a namespace the runner's ServiceAccount can
read. It is a schema-and-RBAC gate.
--atomic is also what makes the always() show step matter more here than
in deploy-k8s.yml: by the time anyone reads the log, Helm has already rolled
the release back, so the failed objects are gone and the dumped events are the
only surviving record of why it never went ready. The step prints
helm history alongside them for the same reason.
Deploy caller — a pinned chart, values from the repo, atomic:
helm:
permissions:
contents: read
uses: dustfeather/shared-workflows/.github/workflows/deploy-helm.yml@v4
with:
namespace: nextcloud
release: nextcloud
chart: nextcloud/nextcloud
chart-version: "9.1.0"
repo-url: https://nextcloud.github.io/helm/
values-files: helm/nextcloud-values.yaml
runner: arc-itguys-ro-nextcloud
setup-kubectl: true
atomic: true
helm-timeout: 5m
rollout-targets: deploy/nextcloud
rollout-timeout: 5mThe same workflow as a PR gate — one word different, and nothing is written:
validate-chart:
uses: dustfeather/shared-workflows/.github/workflows/deploy-helm.yml@v4
with:
namespace: nextcloud
release: nextcloud
chart: nextcloud/nextcloud
chart-version: "9.1.0"
repo-url: https://nextcloud.github.io/helm/
values-files: helm/nextcloud-values.yaml
runner: arc-itguys-ro-nextcloud
setup-kubectl: true
lint: true
template-check: true
upgrade: falseEach calling repo has a tiny shim. The shim handles trigger configuration
(which can't be inside a workflow_call workflow); the central workflow
handles all the logic.
name: Claude Code Review
on:
pull_request:
types: [opened, synchronize]
# Avoid the claude-code-action self-modify validation guard tripping
# on PRs that change this very file. Keep the path narrow — broader
# filters like '.github/**' would also skip review on workflow PRs
# that are unrelated to this file.
paths-ignore:
- '.github/workflows/claude-code-review.yml'
jobs:
review:
uses: dustfeather/shared-workflows/.github/workflows/claude-code-review.yml@v4
secrets: inheritname: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
uses: dustfeather/shared-workflows/.github/workflows/claude.yml@v4
secrets: inheritnode-test.yml, deploy-cloudflare.yml and release-extension.yml accept an
optional NPM_TOKEN and write it into ~/.npmrc before any step touches
registry.npmjs.org, so installs and audits run authenticated rather than
anonymously.
Mint it on npmjs.com as a granular access token, read-only, with no package, scope or organization grants — nothing here publishes, and nothing here installs a private package, so any grant beyond that is blast radius for no gain. Classic tokens were removed in November 2025, so granular is the only kind available; they carry a mandatory expiry and will need rotating.
The token is written as a literal ${NPM_TOKEN} reference, not its value:
//registry.npmjs.org/:_authToken=${NPM_TOKEN}
npm, pnpm and yarn all expand env references when they read .npmrc, so the
secret is resolved at use time and never lands on disk, in a restored cache
layer or in an uploaded artifact.
The step is guarded on a non-empty value and the secret is required: false.
An unset or misnamed secret interpolates to the empty string, which is
indistinguishable from "not configured" — and writing an empty _authToken
turns every registry call into ENEEDAUTH, which is strictly worse than
anonymous. So the step skips instead, and a caller that has not been wired up
keeps working unchanged.
Note this is a hardening measure, not a fix for a broken audit: as of
2026-09-04 both /-/npm/v1/security/audits/quick and
/-/npm/v1/security/advisories/bulk answer HTTP 200 unauthenticated. If the
audit step fails, check for the hang described in issue #24 before suspecting
credentials.
Each calling repo must have CLAUDE_CODE_OAUTH_TOKEN set — either as a
repository secret or inherited from an organization secret. The shim then
needs to pass it through.
Reusable workflows run in the caller's context with the caller's secrets. The central repo's secrets are not visible to callers; each caller pays for its own Claude usage with its own token.
When the calling repo is owned by the same account as shared-workflows
(i.e. another dustfeather/* repo), the shim can use:
jobs:
review:
uses: dustfeather/shared-workflows/.github/workflows/claude-code-review.yml@v4
secrets: inheritinherit carries every secret the caller can see — both repo-level and
org-level — into the called workflow.
When the caller lives under a different owner (e.g. an ITGuys-RO/*
repo calling dustfeather/shared-workflows), secrets: inherit does NOT
reliably pass org-level secrets with "selected" visibility across the
owner boundary. You'll see this error from the called workflow's first
job:
Error when evaluating 'secrets'. .github/workflows/claude-code-review.yml
(Line: N, Col: M): Secret CLAUDE_CODE_OAUTH_TOKEN is required, but not
provided while calling.
The fix is to pass the secret explicitly. The reference is resolved in the caller's context (where the org secret IS visible to the repo) and forwarded as a named secret:
jobs:
review:
uses: dustfeather/shared-workflows/.github/workflows/claude-code-review.yml@v4
secrets:
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}This works in both cases (same-owner and cross-owner), so when in doubt,
prefer the explicit form. inherit is just a convenience shortcut for
the same-owner case.
Both workflows accept optional with: inputs:
| Input | Workflow | Default | Purpose |
|---|---|---|---|
trusted-actors |
both | dustfeather |
Comma-separated GitHub actors allowed to trigger |
allowed-bots |
review | dependabot[bot] |
Bots whose PRs trigger reviews |
show-full-output |
review | true |
Surface every tool call in the action log |
Example (private repo where another teammate should also be able to invoke):
jobs:
review:
uses: dustfeather/shared-workflows/.github/workflows/claude-code-review.yml@v4
with:
trusted-actors: "dustfeather,collaborator-handle"
secrets: inheritCallers pin to @v4 (the current moving major-version tag, GitHub Actions
convention). Every push to main is auto-tagged by
.github/workflows/tag-release.yml: by default it bumps the patch
component by one (wrapping at 100 into minor; minor grows without bound),
and it re-points the floating vN tag at the new commit. major is never
bumped automatically — a major bump can break @vN callers, so it only
happens when you ask for it. To request a larger bump for a given commit,
put a token in that commit's subject line — #minor or #major (largest
wins; #patch is the explicit form of the default).
Pick the bump by what changes for callers of these reusable workflows:
| Bump | Token | When | Caller impact |
|---|---|---|---|
| patch | (default, or #patch) |
Bug fix in a workflow; doc-only change; internal refactor; bumping an action used inside a workflow with no interface change; log/wording tweaks. | None — @v4 callers get it automatically, nothing to do. |
| minor | #minor |
Backwards-compatible feature: a new optional input (with a default), a brand-new workflow, a new opt-in job/step, broadened behavior that callers don't have to react to. | None required; new capability is available if they want it. |
| major | #major |
Breaking change to a workflow's contract: removing/renaming an input or secret, adding a required input, changing a default in a way callers must account for, requiring callers to grant new permissions, removing a workflow, renaming a job output. | Callers on @v4 would break. A #major bump rolls the version to vN+1; update the README usage examples and tell callers to re-pin to @vN+1. |
Rule of thumb: if a caller's shim workflow could keep working untouched → patch or minor; if it couldn't → major. When unsure, prefer the larger bump.
Note: the token match is a fixed-string search of the commit subject, so
don't write #major/#minor verbatim in a commit message unless you mean
it (e.g. say "the major token" rather than the literal string).
For each browser-extension repo's release.yml (after a build job that
uploads an artifact named extensions containing the packaged .zip,
.xpi, source .zip, and release-notes.txt):
publish-chrome:
needs: build
uses: dustfeather/shared-workflows/.github/workflows/publish-chrome.yml@v4
with:
zip-name: my-ext-chrome-${{ needs.build.outputs.tag }}.zip
secrets: inherit
publish-firefox:
needs: build
uses: dustfeather/shared-workflows/.github/workflows/publish-firefox.yml@v4
with:
xpi-name: my-ext-firefox-${{ needs.build.outputs.tag }}.xpi
source-name: source-${{ needs.build.outputs.tag }}.zip
addon-id: my-ext@dustfeather
secrets: inheritsecrets: inherit passes through CHROME_* and AMO_* secrets from the
calling repo. If a repo doesn't have a given store's secrets configured,
the matching publish job emits a ::warning:: and exits 0 cleanly.
This repo subsumes the earlier dustfeather/extension-workflows (which
was extension-publish-only). All publish workflows now live here so
there's one home for shared CI.