Wire the bitwise reusable workflows
The org centralises CI, security scanning, release, and the fast-forward /merge automation in the
bitwise-media-group/github-workflows library, and its mise task contract in
bitwise-media-group/toolchain. A consuming repo keeps a thin caller in .github/workflows/
that owns its triggers and grants a permission ceiling, then delegates the work to a reusable
workflow with uses:. Copy a caller from this skill's templates/ directory and pin it
by SHA.
Author bespoke workflows with the actions-style skill, harden them with actions-security, and
run actions-validate before committing.
1. The caller pattern
The caller declares on:, sets the permissions: ceiling the reusable workflow's jobs need (a
reusable workflow can never exceed the caller's grant), and calls one workflow per job:
name: Continuous integration
on:
push:
branches: [main]
pull_request:
branches: ["main", "releases/*"]
permissions:
contents: read
jobs:
ci:
uses: bitwise-media-group/github-workflows/.github/workflows/ci.yaml@1eaff010e2929a3583c50ffa325690216bc70462 # v6.2.0
A caller may add product-specific jobs (an integration job, say) alongside the reusable-workflow
job. None of these workflows use pull_request_target to run PR code.
2. Catalog
Every reusable workflow lives in the same repo and releases together — one tag, one commit, covers
ci.yaml, security.yaml, release.yaml, every merge workflow, and add-to-project.yaml at once.
Grant exactly the listed permission ceiling and nothing more.
Always wire these
ci.yaml(template) — runs the canonical mise tasks (lint,build,test, optionale2e) as parallel jobs, sets up only the toolchains the repo has (go.mod→ Go,package.json→ Node,pyproject.toml→ Python viauv), verifies a committeddist/stays reproducible, and uploads coverage to Codecov. Caller grantscontents: read. Inputs:go-version-file,node-version-file,cache-dependency-path,e2e(defaultfalse),coverage(defaulttrue; setfalsefor repos that emit neither coverage nor test results). No secrets.security.yaml(template) — CodeQL overactions+go+javascript-typescript, by detection. Caller grantssecurity-events: write,packages: read,actions: read,contents: read. Inputs:go-version-file,config-file(pass./.github/codeql/codeql-config.yaml, template, to exclude a bundleddist/),languages(override auto-detection — e.g. a tooling-onlypackage.jsonthat shouldn't add an emptyjavascript-typescriptleg). No secrets.release.yaml(template) — release-please (two-pass) → GoReleaser (when a.goreleaser.yamlexists) and/or a Zensical docs build published to GitHub Pages (when azensical.tomlexists) — independent of each other, so one release can ship both. Caller grantscontents/issues/pull-requests: write, plusid-token/attestations/artifact-metadata/packages/pages: write— grant all seven even without a.goreleaser.yamlorzensical.toml: GitHub unions a reusable workflow's job permissions and ignoresif:, so a missing scope fails the run at startup regardless of which jobs actually run. Inputs:vanity-tags(defaultfalse; set it for Actions/reusable repos pinned@v1),app-client-id(optional — author the release PR as the "FF Merge" App instead ofGITHUB_TOKEN, needed only if you auto-merge release PRs viamerge.yaml, since aGITHUB_TOKENpush emits noworkflow_runevent). Optional secretshomebrew-tap-token,app-private-key(required only whenapp-client-idis set). Outputs:release_created,tag_name,sha,version,major,minor,patch— gate a follow-on job withif: needs.release.outputs.release_created(using the caller's own job id).merge.yaml(template) — signature-preserving fast-forward/mergeplus set-and-forget auto-merge. Caller grantspermissions: {}(the App token does the privileged work). Inputs:app-client-id(required),merge-command/arm-command/label(default/merge//auto-merge/auto-merge),require-approval/maintainer-only(defaulttrue),squash-authors(defaultbitwise-renovate[bot]— authors whose PRs are squash-merged via the API instead of fast-forwarded, since their branches never rebase onto the base). Required secretapp-private-key. Triggersissue_comment,workflow_run(list your CI workflow name(s) plus"Merge Review Ack"), and aschedulebackstop sweep — deliberately no PR-attached event, so the flow adds no skipped-check clutter to a PR.merge-review-ack.yaml(template) — required companion tomerge.yaml: carries the approval signal (pull_request_review, whichmerge.yamldeliberately doesn't subscribe to) back in as aworkflow_run, so fork and same-repo PRs auto-merge identically the moment they're approved. Caller grantspermissions: {}, no inputs, no secrets — add"Merge Review Ack"tomerge.yaml'sworkflow_run.workflowslist. Its singleackjob is the only check this whole system adds to a PR.merge-notice.yaml(template) — posts a one-time "this repo merges via/merge" comment on new PRs. Caller grantspull-requests: write. Input:pr-number(required). No secrets.add-to-project.yaml(template) — adds newly opened issues to a shared org Projects v2 board via a "Project Sync" App token (GITHUB_TOKENcannot write an org-level project). Caller grantspermissions: {}. Inputs:project-url(required;https://github.com/orgs/<org>/projects/<n>),app-client-id(required),labeled/label-operator(optional label filter, default operatorOR). Required secretapp-private-key.
3. The consumer contract: mise tasks, not raw Makefile targets
The reusable workflows stay config-free by assuming a small contract, and as of the v3+ line
the mise config is the language boundary, not the Makefile — ci.yaml runs mise run <task>
directly, so a Makefile isn't required at all.
- mise tasks —
lint,build,test(emittingcoverage/cobertura-coverage.xml, optionallycoverage/junit.xml), ande2e, defined in a rootmise.tomlor supplied by the shared task library (below).ci.yamldiscovers the task list withmise tasks ls --name-onlyand skips whichever of those four the repo hasn't defined — no no-op stubs needed (drop any leftoverbuild: ; @:from the old Makefile-only contract). - Toolchain detection — a root
go.modsets up Go (setup-go, fromgo-version-file); a rootpackage.jsonsets up Node (setup-node); a rootpyproject.tomlsets up Python viaastral-sh/setup-uv(uv readsrequires-python/.python-versionitself — nosetup-python). A tools-onlygo.work+tools/go.modis dev tooling, not a product signal, so it does not trigger the Go path — only a rootgo.moddoes. - Release —
release-please-config.json+.release-please-manifest.json; an optional.goreleaser.yaml(release-type: go,draft: true) selects the GoReleaser path; an optionalzensical.toml(+pyproject.toml+uv.lock, with repo Settings → Pages → Source set to GitHub Actions) selects the docs-publish path independently of GoReleaser. A committeddist/is verified fresh byci.yamlon every PR, not at release time.
Wire the toolchain library
A repo gets the mise tasks above from bitwise-media-group/toolchain
(latest v2.4.1), mounted as
a submodule at .mise/:
git submodule add https://github.com/bitwise-media-group/toolchain.git .mise
git -C .mise checkout v2.4.1
git add .mise && git commit -m "chore: add bitwise-media-group/toolchain submodule"
Then pick an archetype in a root mise.toml and reduce the Makefile to one include, kept only for
make <target> muscle memory and local interchangeability — CI never invokes it:
# mise.toml
[task_config]
includes = [".mise/tasks/go-cli.toml"] # or node-action / node-lib / docs-site / markdown-lib / terraform
# Makefile — the whole thing
include .mise/mise.mk
Run mise trust --all once per clone (CI trusts the workspace automatically). The library's
config.toml also supplies the pinned developer tools, [settings], and the universal
license/prose/container/shell lint tasks every archetype shares — see the library's own README.md
for the full task/knob reference, and the terraform-validate skill for the terraform.toml
archetype specifically. Per that same README, the submodule itself bumps via Dependabot's
gitsubmodule ecosystem; the tool pins inside config.toml bump via Renovate as normal (§7) —
never run mise lock/mise upgrade inside a consumer repo, that writes into the submodule's
working tree.
A caller may still mix a reusable-workflow job with normal jobs — e.g. a Go CLI keeps a
product-specific integration job in the same ci.yaml that calls the reusable ci.yaml.
4. Pinning
Every uses: line in this skill's templates pins the same full commit SHA, with the release tag as
a trailing comment:
uses: bitwise-media-group/github-workflows/.github/workflows/ci.yaml@1eaff010e2929a3583c50ffa325690216bc70462 # v6.2.0
A floating tag (@v6, @v6.2) is simpler to read but mutable — see the actions-security skill for
why that matters on a workflow with elevated permissions. Prefer the SHA; Renovate's
github-actions manager bumps either form, and for a SHA pin it updates the trailing comment too.
The upstream repo's own illustrative snippets are an unreliable guide to "latest" — they show
inconsistent floating placeholders (@v2 on most workflows, @v4 on add-to-project.yaml, @v6 on
update-tools.yaml) reflecting whatever major existed when each example was last touched, not the
current release. Resolve the real latest before pinning — and when the SHA above goes stale, the same
way: git ls-remote --tags https://github.com/bitwise-media-group/github-workflows | sort -t/ -k3 -V
for the newest tag, then git rev-parse <tag> against a local clone for its commit. Avoid @main
except for short-lived testing against a feature branch.
5. Fast-forward merge: org setup
merge.yaml, merge-review-ack.yaml, and merge-notice.yaml drive the
bitwise-media-group/ff-merge action via a short-lived GitHub App token (so commit signatures
survive). The one-time, org-wide setup — the "FF Merge" App, its ruleset bypass, the
FF_MERGE_CLIENT_ID variable, and the FF_MERGE_PRIVATE_KEY secret — is documented in
bitwise-media-group/ff-merge. The contract is
input app-client-id (from vars.FF_MERGE_CLIENT_ID) + secret app-private-key (from
secrets.FF_MERGE_PRIVATE_KEY); align older callers using client-id/app-key to these names. The
merge flows also require branch protection that requires PR review.
6. Add to Project: org setup
add-to-project.yaml needs its own GitHub App — "Project Sync" — separate from "FF Merge": the merge
flows act within a repo, while writing to an org-level Projects v2 board needs organization-scoped
permissions no repo-level App grant covers. Create it with organization projects read/write plus
repository issues + pull requests read, install it on every repository, and expose it as the
ADD_TO_PROJECT_CLIENT_ID variable + ADD_TO_PROJECT_PRIVATE_KEY secret. In this org the caller
itself is fanned out to every repo automatically by org-config.sh workflows-sync in
github-settings, so copying the
template by hand is only needed for a repo outside that sync.
7. Dependency updates: Renovate, not Dependabot
The org replaced Dependabot with a self-hosted Renovate bot
(bitwise-media-group/renovate-config).
It runs hourly with an App-installation token (so its commits are API-created and GitHub-signed —
Verified), autodiscovers every repo the "Renovate" App is installed on, and every discovered repo
inherits the org preset with no onboarding file — do not add a .github/dependabot.yaml or a
dependabot-merge.yaml caller. Renovate squash-merges its own green, approved PRs via the API
(the merge.yaml caller's squash-authors input defaults to bitwise-renovate[bot]), so getting a
repo covered is exactly: get the App installed (an org-level change, not a per-repo one).
A repo only needs its own .github/renovate.json5 for a deliberate override on top of the preset
(an ignored dependency, a postUpgradeTasks rebuild rule for a committed bundle, …):
{
extends: ["github>bitwise-media-group/renovate-config:default.json5"],
// overrides here
}