Reusable GitHub Actions CI for the Coroboros stack.
Drop into any @coroboros/* repo via uses: coroboros/ci/.github/workflows/<name>.yml@v0, or compose around the composite actions under .github/actions/.
Imposed, not proposed. Pipelines expose zero inputs:. Every Coroboros repo inherits identical install flags, publish auth, and security gates. Consumers wire it in.
- Architecture
- Pipelines
- Composable actions
- Development flow
- Self-CI
- Environment
- Security
- Examples
- Contributing
- License
flowchart TB
C["Consumer repo<br/>.github/workflows/ci.yml"]
subgraph CI["coroboros/ci"]
direction TB
WF["Reusable workflows<br/>javascript-npm-packages · rust-packages<br/>security-gate · security"]
CA["Composite actions<br/>.github/actions/*"]
WF --> CA
end
R[("Imposed rulesets<br/>deny.toml · .gitleaks.toml")]
C ==>|"uses @v0"| WF
CA -.->|"sparse-checkout"| R
Three layers:
- Consumer — a repo's
.github/workflows/ci.ymlcalls a reusable workflow withuses: coroboros/ci/.github/workflows/<name>.yml@v0. - Reusable workflows —
javascript-npm-packages/rust-packagesorchestrate the pipeline:publishneeds:the blockingsecurity-gate, while the advisorysecurityruns in parallel and never blocks. - Composite actions (
.github/actions/*) — the shared steps; the security composites sparse-check the canonical rulesets (security/{deny.toml,.gitleaks.toml}) from this repo at runtime.
The GitHub-Actions sibling of coroboros/ci on GitLab — the same osv-scanner, gitleaks, and cargo-deny gate, expressed as reusable workflows instead of GitLab templates.
Pin @v0 (rolling major, tracks the latest release) or @x.y.z (the workflow file; nested composites still follow @v0).
Requirements
- Files —
.node-version,package.json,pnpm-lock.yaml,README.md. package.json—packageManager: "[email protected]"(pnpm is the only supported manager, run via corepack),scripts.lint,scripts.test;scripts.buildoptional.- Secrets — see Environment.
Jobs
security-gate
Trigger: every push — gates publish.
Calls security-gate.yml: osv-scanner + gitleaks. A vulnerable dependency or leaked secret blocks the release. See Security.
publish
Trigger: tag push — gated by security-gate.
- Checkout
main verify-tagcheck-docsjavascript/base- Pin
package.jsonto the tag generate-changelog- Publish to npm — auth: Security
github-releasecommit-artifacts
Requirements
- Files —
rust-toolchain.toml,Cargo.toml, committedCargo.lock,README.md. - Compile-time assets (
include_str!,build.rsinputs) must stay in the package — thepackagejob verify-builds it. - Optional hooks —
ci/setup.sh(native build deps),ci/test.env+ci/test-setup.sh(test fixtures). - Secrets — see Environment, all optional. Binary distribution is opt-in (below); the cargo-deny policy is imposed, no consumer config (see Security).
Jobs
preflight
Trigger: branch push — matrix ubuntu / macos / windows.
check-docsrust/base— fmt, clippy, test
security-gate
Trigger: every push — gates publish.
Calls security-gate.yml: cargo-deny + gitleaks. License policy runs advisory in security. See Security.
package
Trigger: branch push
Verify-builds the packaged crate, so a compile-time asset dropped from the package fails the PR rather than the tagged publish.
publish
Trigger: tag push — gated by security-gate; skipped if dist-build fails.
- Checkout
main verify-tagcheck-docsrust/basepin-versiongenerate-changelogcargo publish— auth: Securitygithub-releasecommit-artifacts
binary distribution (opt-in)
Trigger: tag push, when Cargo.toml declares [package.metadata.dist]. Library crates self-skip.
dist-plan— per-target build matrixdist-build— per-target archivesdist-host— installers, Homebrew formula, npm shim; uploads assets, undrafts the releasedist-publish— tap + npm shim
The pipeline owns the single release; cargo-dist only builds. Consumer config — the cargo-dist metadata, with allow-dirty = ["ci"] in [workspace.metadata.dist]. Per-target features via cfg; shared binaries are CPU-only. HOMEBREW_TAP_TOKEN / NPM_PACKAGE_REGISTRY_TOKEN optional.
The blocking gate, split from the advisory layer so it can be owned as a black box. Two parallel jobs, both fail the release through the caller's needs: graph — a dev can't bypass them:
supply-chain— auto-routed by ecosystem: aCargo.tomlrepo runssecurity/rust/cargo-deny(advisories + bans + sources); any other runssecurity/osv-scanner. One tool per repo, never both, so a crate isn't vuln-scanned twice. A repo with no supported manifest skips (osv's no-manifest path).secret-scan—security/gitleaks, full git history, canonical ruleset.
Imposed on every package pipeline (a security-gate job needs:-ed by publish) and importable directly by a non-package repo. Holds only what blocks: a compromised dependency or a leaked secret. License and quality policy live in security.yml.
The advisory layer — reports, never blocks (parity with GitLab's allow_failure: true):
dependency-review— PR-only; needs repo's Dependency graph enabled. Fails on high-severity CVE introduced by the dep diff. Usesactions/dependency-review-action@v4.licenses— Rust-only (continue-on-error):security/rust/cargo-denychecks: licensesagainst the canonical allow-list. A non-allowed license is surfaced, never blocks the release. Skips a repo with noCargo.toml.
| Action | Type | Purpose |
|---|---|---|
check-docs |
transverse | Context dump + documentation check. |
javascript/base |
JavaScript | Sets up Node + corepack pnpm, caches the store, writes .npmrc from env, then installs, lints, builds (when present), tests. |
rust/base |
Rust | Installs the toolchain, caches via rust-cache, runs rust/native-deps, then fmt, clippy, rust/test-deps, test. |
rust/native-deps |
Rust | Runs the optional ci/setup.sh native build-dependency hook (sees CARGO_DIST_TARGET on a dist-build cross leg). Shared by rust/base and the dist-build matrix. No-op when absent. |
rust/test-deps |
Rust | Loads the optional ci/test.env into the job env and runs the optional ci/test-setup.sh fixture hook before cargo test. Used by rust/base. No-op when absent. |
rust/install-dist |
Rust | Installs cargo-dist's dist binary, prebuilt and SHA-256 verified (Linux/macOS/Windows). Shared by the dist-plan, dist-build, dist-host jobs. |
rust/pin-version |
Rust | Installs version-pinned cargo-set-version (cargo-edit) and stamps Cargo.toml to the release tag. Shared by publish and the dist-* jobs. |
security/gitleaks |
transverse | Installs gitleaks (SHA-256 verified), scans with the canonical ruleset, emits SARIF. Behind security-gate.yml's secret-scan and self-CI. |
security/osv-scanner |
transverse | Scans dependency manifests for known vulnerabilities (OSV.dev); skips a repo with no supported manifest. Behind security-gate.yml's supply-chain (non-Rust) and self-CI. |
security/rust/cargo-deny |
Rust | Runs cargo-deny against the canonical imposed security/deny.toml (sparse-checked from coroboros/ci, no consumer override). The checks input selects which checks run — advisories bans sources for the security-gate.yml supply-chain, licenses for the security.yml advisory layer. |
release/verify-tag |
transverse | Fails the release unless the checked-out main HEAD matches the tag SHA. Shared by the npm and Rust publish jobs — the tag-time jobs that check out main to push back; the dist-* jobs pin to the tag commit (github.sha) instead. |
release/generate-changelog |
transverse | SemVer-strict tag guard + generates or reuses the ## vX.Y.Z section in CHANGELOG.md from Conventional Commits. Outputs body. Idempotent. |
release/github-release |
transverse | Creates the GitHub Release for the current tag, optionally as a draft. Body typically chained from release/generate-changelog. |
release/commit-artifacts |
transverse | Stages the given files and commits them back to main as chore: release ${tag} [skip ci]. No-op when nothing changed. |
Develop with Conventional Commits → tag → push. No manual CHANGELOG, no version bump.
Tags follow SemVer strict — 1.2.3, never v1.2.3.
Branch models
main-only — feature branch → PR → squash-merge to main → tag the merge commit → push.
develop + main — PR into develop → tag → release/x.y.z branch → merge to main → main reflects production.
Nobody pushes directly to protected branches (main, develop, release/x.y.z).
Conventional Commits → CHANGELOG
| Commit type | CHANGELOG subsection |
|---|---|
feat |
Features |
fix |
Fixes |
refactor |
Refactor |
perf |
Performance |
docs |
Documentation |
chore / ci / build |
Configuration |
test |
Tests |
style |
Style |
| Other / non-standard | Others |
!: or BREAKING CHANGE: |
Breaking Changes (always first) |
Section format: ## vX.Y.Z - DD/MM/YYYY. Idempotent. Reuses an existing hand-curated section for the tag if present.
coroboros/ci runs a CI on itself — lint, security, and the v0 release move — plus a test layer that exercises its own composite actions, which are the product:
- Lint (
self-lint.yml) —actionlint,yamllint,shellcheck. - Security (
self-security.yml) — thegitleaks/osv-scannercomposites and thesecurity-gate/securityworkflows, via local./refs. - Release (
self-release.yml) — moves the rollingv0tag onto each stable release. - Test (
self-test.yml) — smoke every composite (release/*,rust/*,security/*) against the real checkout, and runjavascript/base+rust/baseend-to-end on atest/fixtures/package and crate.
A workflow self-test resolves its composites at the released @v0, so a brand-new composite is testable through a workflow only once a release moves v0 onto it.
Zero inputs: — configuration flows through the caller's secrets: block. Every value is a secret (encrypted at rest, masked in logs), never a GitHub var.
Secrets — javascript-npm-packages.yml
| name | required | description |
|---|---|---|
NPM_CONFIG_FILE |
yes | .npmrc content. Written to repo root by javascript/base. ${VAR} references inside are expanded by npm at install time. |
NPM_EXTRA_CONFIG |
Extra .npmrc lines appended after NPM_CONFIG_FILE. A secret — it lands in .npmrc, so it can carry auth material and must stay masked. |
|
NPM_PACKAGE_REGISTRY |
yes | npm package registry URL. |
NPM_PACKAGE_PROXY_REGISTRY |
Optional npm proxy registry URL. | |
NPM_PACKAGE_REGISTRY_TOKEN |
npm Granular Access Token, scoped to the publishing organization with create-new-package permission. Required only for the token bootstrap (first publish of a new scoped package, before npm Trusted Publisher is bound). Absent → OIDC. |
Secrets — rust-packages.yml
All optional. A consumer that wires none still gets crates.io plus prebuilt archives and installers on the release; Homebrew and npm activate only when their secret (or OIDC) is configured.
| name | required | description |
|---|---|---|
CARGO_REGISTRY_TOKEN |
crates.io token. Bootstraps the first publish of a new crate; absent → OIDC Trusted Publishing. | |
HOMEBREW_TAP_TOKEN |
Push access to the Homebrew tap repo named by tap in [package.metadata.dist]. Absent → the formula publish self-skips. |
|
NPM_PACKAGE_REGISTRY_TOKEN |
npm token bootstrapping the first publish of the binary npm shim; absent → OIDC Trusted Publisher. The shim publishes with provenance either way. |
Three pillars: a blocking security-gate that stops a release on a known vulnerability or leaked secret; supply-chain hardening at the workflow layer (npm firewall + cooldown, Rust cargo-deny); and SHA/version-pinned tooling under imposed canonical rulesets.
Supply chain — npm
The target: a hijacked maintainer, a typosquat, a postinstall payload, or a fresh bad version pulled before it is caught. javascript/base enforces four layers — the runner equivalent of the GitLab pipeline's image-baked hardening:
| Layer | Mechanism | Where |
|---|---|---|
| Cooldown | versions under 7 days old are quarantined; @coroboros/* excluded so internal publishes flow immediately |
consumer pnpm-workspace.yaml |
| Firewall | Socket Firewall (sfw) proxies the fetch, blocks confirmed-malicious packages before download |
javascript/base |
| No install scripts | --ignore-scripts blocks postinstall code execution |
javascript/base + .npmrc |
| Frozen lockfile | --frozen-lockfile rejects a stale or tampered pnpm-lock.yaml |
javascript/base |
Cooldown is consumer config — pnpm 11 reads pnpm-workspace.yaml (minimum-release-age in .npmrc on pnpm 10.x):
# pnpm-workspace.yaml
minimumReleaseAge: 10080 # 7 days, in minutes
minimumReleaseAgeExclude:
- '@coroboros/*' # internal packages install immediatelyHonest gaps. sfw is fail-closed — if it can't install or run, the job fails rather than fetch unprotected. It inspects public-registry fetches out of the box; packages pulled through a private proxy pass uninspected, held instead by the cooldown. pnpm itself is corepack-resolved from packageManager, so no floating version reaches the runner.
Supply chain — Rust
GitHub-hosted runners share no hardened base image, so the Rust pipeline enforces its supply-chain controls in the workflow:
| Risk | Rust control |
|---|---|
| Untrusted source, typosquat | cargo-deny sources — crates.io only; git and alternative registries denied |
| Lock drift, tampered dependencies | committed Cargo.lock + --locked on clippy and test — fails on a stale or altered lock |
| Known vulnerability | cargo-deny advisories — RustSec vulnerabilities, unmaintained, unsound, yanked |
| License drift | cargo-deny licenses — allow-list, advisory (reports, never blocks) |
| Banned or wildcard dependency | cargo-deny bans |
The blocking checks run in security-gate.yml; licenses runs advisory in security.yml. The policy is imposed — cargo-deny applies the canonical security/deny.toml via --config, sparse-checked from coroboros/ci (the gitleaks model). A consumer deny.toml is ignored, a deny.exceptions.toml fails the job. An unfixable transitive advisory is suppressed centrally — a PR adds a justified ignore = ["RUSTSEC-…"] to the canonical deny.toml, never per repo.
Publish auth. crates.io uses OIDC Trusted Publishing by default — a short-lived token per run, no stored secret. CARGO_REGISTRY_TOKEN only bootstraps the first publish of a new crate; configure Trusted Publishing afterwards and drop it. The verify build runs on cargo publish (no --no-verify), catching a crate that only builds in-workspace before the immutable release.
Two residual risks have no clean CI control. Both are documented here:
- Build scripts run.
cargohas no--ignore-scripts;build.rsand proc-macros execute at build time.--locked,cargo-denybans, and dependency review reduce the exposure; they do not remove it. - No publish cooldown. crates.io has no
minimumReleaseAge, so a freshly hijacked version is held off by the committed lock andcargo-denyadvisories rather than a time delay.
Recommended NPM_CONFIG_FILE contents
Minimal hardened .npmrc for every Coroboros consumer. Stored as a secret (encrypted; carries ${VAR} expansions resolved at install time):
@coroboros:registry=https:${NPM_PACKAGE_REGISTRY}
save-exact=true
fund=false
audit=false
ignore-scripts=true
package-lock=false
lockfile=true
prefer-online=true| Line | Why |
|---|---|
@coroboros:registry=https:${NPM_PACKAGE_REGISTRY} |
Scope-resolved registry — ${NPM_PACKAGE_REGISTRY} expands from the same-named secret. |
save-exact=true |
Pin exact versions on add / install. |
fund=false |
Suppress funding noise in CI logs. |
audit=false |
osv-scanner (in security-gate.yml) covers vulnerability scans natively. |
ignore-scripts=true |
Defense in depth against postinstall supply-chain attacks — backs up the --ignore-scripts flag already passed by javascript/base on every pnpm install. |
package-lock=false |
Prevent npm from emitting a parasitic package-lock.json in pnpm repos. |
lockfile=true |
Explicit pnpm-lock.yaml enablement. On pnpm 10 the preceding package-lock=false is read as lockfile=false, which collides with --frozen-lockfile; pnpm 11 defaults to true and ignores package-lock here, so the line is harmless. |
prefer-online=true |
Re-fetch dep metadata each install — local cache cannot mask a yanked or republished version. |
Publish — OIDC vs token bootstrap
Auto-detected by NPM_PACKAGE_REGISTRY_TOKEN secret presence on the consumer repo:
| Token secret | Mode | Command |
|---|---|---|
| absent | OIDC + provenance (default) | pnpm publish --provenance --no-git-checks |
| present | Token bootstrap | npm publish --ignore-scripts --access public |
OIDC + provenance — no long-lived token in the repo; npm trusts a per-run id-token issued by GitHub Actions for coroboros/<repo>/ci.yml. Requires the npm Trusted Publisher form, which only accepts an existing package — so the very first publish has to take the token bootstrap below.
Token bootstrap — publishes the first version of a new scoped package. Set two additional secrets on the consumer (encrypted; forwarded via the caller's secrets: block):
| Secret | Contents |
|---|---|
NPM_PACKAGE_REGISTRY_TOKEN |
npm Granular Access Token scoped to the publishing organization with create-new-package permission. Long-lived; revoke after migrating to OIDC. |
NPM_EXTRA_CONFIG |
${NPM_PACKAGE_REGISTRY}:_authToken=${NPM_PACKAGE_REGISTRY_TOKEN} — appended to .npmrc by javascript/base. Stored as a secret because it carries auth expansion. |
npm publish is used on the bootstrap path (not pnpm publish) because pnpm 11 in CI auto-attempts the OIDC token exchange and won't fall back to the .npmrc token. --ignore-scripts --access public skips publish-time lifecycle hooks (prepublishOnly excepted — known npm behavior). The tarball is identical to pnpm publish's.
After the first publish, configure the npm Trusted Publisher form (Publisher type: GitHub Actions; Organization: the publishing org; Repository: consumer repo; Workflow filename: ci.yml; Environment: empty), then open a chore(ci): PR dropping NPM_PACKAGE_REGISTRY_TOKEN + NPM_EXTRA_CONFIG from the caller's secrets: block. Revoke the npm token. 1.0.1+ publishes via OIDC + provenance.
Pinning & imposed rulesets
- Action pinning. Third-party actions are pinned to a commit SHA with an inline
# vXcomment — no floating@main/@vX. Self-CI tooling pins by version with SHA-256-verified tarballs, nocurl | bash..github/dependabot.ymlauto-PRs the SHA bumps; renovate auto-PRs the tool versions. - Imposed rulesets.
security/deny.tomlandsecurity/.gitleaks.tomlare sparse-checked from this repo at runtime — a consumer can't override them. The gitleaks ruleset adds Resend, Neon, PostHog, and GitHub PAT rules on top of the defaults. - Secret isolation. Each
workflow_call.secrets:block declares only the secrets the job consumes — nosecrets: inherit.
javascript-npm-packages.yml wire-up
# consumer-repo/.github/workflows/ci.yml
name: CI
on:
push:
branches: [develop, main]
tags: ['*']
pull_request:
workflow_dispatch:
jobs:
ci:
uses: coroboros/ci/.github/workflows/javascript-npm-packages.yml@v0
permissions:
contents: write # GitHub Release on tag
id-token: write # npm OIDC publish on tag
secrets:
NPM_CONFIG_FILE: ${{ secrets.NPM_CONFIG_FILE }}
NPM_PACKAGE_REGISTRY: ${{ secrets.NPM_PACKAGE_REGISTRY }}
NPM_PACKAGE_PROXY_REGISTRY: ${{ secrets.NPM_PACKAGE_PROXY_REGISTRY }}
# Token bootstrap (drop both after npm Trusted Publisher is wired — see Security):
NPM_EXTRA_CONFIG: ${{ secrets.NPM_EXTRA_CONFIG }}
NPM_PACKAGE_REGISTRY_TOKEN: ${{ secrets.NPM_PACKAGE_REGISTRY_TOKEN }}rust-packages.yml wire-up
# consumer-repo/.github/workflows/ci.yml
name: CI
on:
push:
branches: [develop, main]
tags: ['*']
pull_request:
workflow_dispatch:
jobs:
ci:
uses: coroboros/ci/.github/workflows/rust-packages.yml@v0
permissions:
contents: write # GitHub Release + commit-back on tag
id-token: write # crates.io + npm OIDC publish on tag
secrets:
# First publish of a new crate only — drop once Trusted Publishing is configured (see Security):
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
# Binary distribution ([package.metadata.dist] in Cargo.toml) — both optional:
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
NPM_PACKAGE_REGISTRY_TOKEN: ${{ secrets.NPM_PACKAGE_REGISTRY_TOKEN }}security on a non-package repo
A repo that ships no package still imports the security workflows directly — the blocking gate plus the advisory layer:
# consumer-repo/.github/workflows/security.yml
name: Security
on:
push:
branches: [develop, main]
pull_request:
schedule:
- cron: '0 0 * * 0' # weekly — catches CVEs published after last push
permissions:
contents: read
jobs:
gate:
uses: coroboros/ci/.github/workflows/security-gate.yml@v0
advisory:
uses: coroboros/ci/.github/workflows/security.yml@v0Internal Coroboros CI. Not open to external contributions — issues and pull requests from outside the organization are not accepted.
All Rights Reserved. See LICENSE.md.