Set up or align GitHub repo settings, branch/ruleset policy, templates, Actions hardening, Environments, release workflows, and deploy workflows for continuously publishable or deployable repositories.
97
100%
Does it follow best practices?
Impact
96%
1.35xAverage score across 7 eval scenarios
Passed
No known issues
Use this reference when wiring the publish step. The verify→release shape stays identical across targets; only the publish plumbing and secrets change.
Before picking an action, inspect the repo's current release files and at least one known-good sibling repo when the organization has one. Release and tap actions have subtle defaults around forks, direct pushes, generated formulae, and token scopes; copying the nearest working pattern is usually safer than inventing a new one.
Default to npm Trusted Publishing from GitHub Actions. Configure the package on npm with the GitHub organization/repo, workflow filename, and release Environment when used; then grant the release job id-token: write and remove NPM_TOKEN. Trusted publishing uses short-lived OIDC credentials and automatically produces npm provenance for public packages from public repos.
Use the npm CLI when enabling trusted publishing for one or many packages. npm trust requires npm 11.10.0 or newer, so pin the operator command to npm@^11.10.0 instead of relying on the local npm version. Login once with a package owner/admin account, then register each package's GitHub workflow identity:
npx -y npm@^11.10.0 login
npx -y npm@^11.10.0 trust github <package-name> --repo <owner>/<repo> --file <workflow-file> --env <environment> --yesExamples:
npx -y npm@^11.10.0 trust github @scope/library --repo scope/library --file ci.yml --env release --yes
npx -y npm@^11.10.0 trust github cli-package --repo scope/cli-package --file release.yml --yesUse --env release when the release job declares environment: release or environment: { name: release }. Omit --env only when the publishing job does not use a GitHub Environment.
Plugins:
"@semantic-release/npm",
"@semantic-release/git",
"@semantic-release/github"Workflow step:
- uses: actions/setup-node@<full-sha> # v6.4.0
with:
node-version-file: ".node-version"
package-manager-cache: false
- run: npm ci
- uses: cycjimmy/semantic-release-action@<full-sha> # v6.0.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}.node-version with the latest active LTS line, currently Node 24.x. Migrate Node version-file examples and workflow setup to .node-version.registry-url for semantic-release npm publishing. With trusted publishing, npm authenticates from the job's OIDC identity rather than a registry token written by setup-node.release Environment, and expose NPM_TOKEN only on the semantic-release step.npm pack --dry-run or the repo's pack smoke before publishing when the package surface is non-trivial."publishConfig": { "access": "public" } in package.json.package.json has a public repository URL that exactly matches the GitHub repo used in the trusted publisher configuration."bin" in package.json and verify the published tarball includes the entry. npm pack --dry-run locally before the first release.Semantic-release tags via @semantic-release/git; CocoaPods publish runs via @semantic-release/exec shelling out to a repo script.
["@semantic-release/exec", {
"prepareCmd": "./scripts/prepare-release.sh ${nextRelease.version}",
"publishCmd": "./scripts/publish-cocoapods.sh"
}],
["@semantic-release/git", {
"assets": ["Package.swift", "<podname>.podspec"],
"message": "chore(release): ${nextRelease.version} [skip ci]"
}],
"@semantic-release/github"prepare-release.sh rewrites the version string in Package.swift and the podspec.publish-cocoapods.sh runs pod trunk push <podname>.podspec --allow-warnings.COCOAPODS_TRUNK_TOKEN exported as env on the publish step. Generate the trunk token with pod trunk register once, then store it as a release Environment secret by default. Use a repository secret only when the repo has an explicit reason not to use a release Environment.Pods/ inside signed or publishing jobs before signing or publishing.Semantic-release does not publish Go binaries. Use it as the version-decider, then hand off to GoReleaser.
Plugins (tag-only — no @semantic-release/git, no source bump):
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
"@semantic-release/github"Two-step release job:
- uses: cycjimmy/semantic-release-action@<full-sha> # v6.0.0
id: release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- if: steps.release.outputs.new_release_published == 'true'
uses: goreleaser/goreleaser-action@<full-sha> # v7.2.2
with:
version: v2.15.4
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAP_GITHUB_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }}
- if: steps.release.outputs.new_release_published == 'true'
uses: actions/attest-build-provenance@<full-sha> # v4.1.0
with:
subject-path: 'dist/*.tar.gz,dist/*.zip'TAP_GITHUB_TOKEN is needed only if GoReleaser publishes to a Homebrew tap in another repo (see Homebrew Tap below).id-token: write and attestations: write to the job's permissions: for the attestation step.--clean wipes dist/ before building so a previous run cannot poison the new release.Two flavors depending on whether you publish to crates.io. Both pair with cargo-dist for cross-platform binaries + Homebrew formula generation (cargo-dist is GoReleaser's Rust equivalent).
Mirrors the Go/GoReleaser shape at the release boundary, but let cargo-dist own the binary distribution workflow. Keep semantic-release as the version-decider (tag-only, no source bump), then let the cargo-dist generated tag workflow build, host, publish, and announce the artifacts.
Plugins (no @semantic-release/git):
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
"@semantic-release/github"Semantic-release job:
- uses: dtolnay/rust-toolchain@<full-sha> # stable
- uses: cycjimmy/semantic-release-action@<full-sha> # v6.0.0
id: release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}CARGO_REGISTRY_TOKEN needed — nothing publishes to crates.io.cargo-dist is a CLI and generated workflow system, not a maintained GitHub Action. Do not use stale axodotdev/cargo-dist-action snippets.cargo dist init or dist generate in the repo so cargo-dist owns the tag-triggered workflow. That generated workflow should cover the plan, build, host, publish, and announce phases; do not replace it with only cargo dist build, which leaves artifacts local to the runner.cargo dist init writes [workspace.metadata.dist] in Cargo.toml. Set tap = "<org>/homebrew-tap" and installers = ["shell", "powershell", "homebrew"].x86_64-unknown-linux-gnu, aarch64-apple-darwin, x86_64-apple-darwin, x86_64-pc-windows-msvc. Add x86_64-unknown-linux-musl for static Linux; aarch64-unknown-linux-gnu for ARM64 Linux.cargo-binstall works out of the box — cargo-dist follows binstall's naming conventions.taiki-e/upload-rust-binary-action@<full-sha> # v1.30.2 in a matrix job.When you do publish to crates.io, swap semantic-release for release-plz. It understands Cargo.toml, handles workspaces, runs cargo publish in dependency order, and generates CHANGELOG.md.
- uses: dtolnay/rust-toolchain@<full-sha> # stable
- uses: release-plz/action@<full-sha> # v0.5.129
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}Cargo.toml + CHANGELOG.md. Merging the PR triggers tag + crates.io publish. Replaces the [skip ci] bump-back loop with an explicit-merge gate.[skip ci] shape), set git_release_enable = true in release-plz.toml only when the repo can push through branch rules with GITHUB_TOKEN, a dedicated release bot, or a narrowly scoped GitHub App token that is explicitly allowed for release pushback. If no narrow release actor can be allowed, keep the default Release PR flow.[[package]] blocks in release-plz.toml.release-plz with @semantic-release/git — pick one version manager. Semantic-release does not understand Cargo.toml lockfile semantics.Cargo.lock for CLI repos (reproducible binary builds); keep it ignored only for pure libraries that explicitly need it.release-plz update --dry-run on a topic branch before the first release.A Homebrew tap is just a separate GitHub repo named homebrew-<tap> (the homebrew- prefix is required) containing one Ruby formula per CLI under Formula/<name>.rb. End users install with brew tap <org>/<tap> then brew install <name>. The release pipeline's job is to keep the formula in the tap repo current.
Whichever flow you pick, you need a token that can push to the tap repo from the source repo's release workflow. The default GITHUB_TOKEN is scoped to the source repo only.
contents: write on the tap repo only.TAP_GITHUB_TOKEN (or similar) in the source repo's release Environment secrets by default. Use a repository secret only when an Environment is intentionally not part of the publish boundary.GoReleaser writes the formula directly. Add a brews: block in .goreleaser.yaml:
brews:
- name: <cli-name>
repository:
owner: <org>
name: homebrew-tap
token: "{{ .Env.TAP_GITHUB_TOKEN }}"
directory: Formula
homepage: "https://github.com/<org>/<repo>"
description: "<one-line description>"
license: "MIT"
test: |
system "#{bin}/<cli-name>", "--version"GoReleaser commits the updated Formula/<cli-name>.rb straight to the tap's default branch on every release. No extra workflow step needed.
First check whether the org already has a non-Go CLI publishing to the same tap. If it does, copy that action and input shape unless the packaging format is different.
For script or binary CLIs whose Homebrew formula can be generated from the GitHub Release archive, Justintime50/homebrew-releaser is the boring direct-to-tap pattern. It clones the source repo and tap repo, generates or updates the formula, and commits straight to the tap branch using the supplied token. Pin the action to a full commit SHA with a same-line version comment matching the version line the working sibling repo uses.
- if: steps.release.outputs.new_release_published == 'true'
uses: Justintime50/homebrew-releaser@<full-sha> # v3.3.0
with:
homebrew_owner: <org>
homebrew_tap: homebrew-tap
formula_folder: Formula
github_token: ${{ secrets.TAP_GITHUB_TOKEN }}
commit_owner: release-bot
commit_email: release-bot@users.noreply.github.com
install: 'bin.install "<cli-name>"'
test: 'system "#{bin}/<cli-name>", "--version"'Use dawidd6/action-homebrew-bump-formula when you explicitly want its version-bump workflow and have verified its fork/direct-push behavior against the tap repo. Default to the tap repo's expected release shape; some setups need a direct push to the tap.
- if: steps.release.outputs.new_release_published == 'true'
uses: dawidd6/action-homebrew-bump-formula@<full-sha> # v7
with:
token: ${{ secrets.TAP_GITHUB_TOKEN }}
tap: <org>/homebrew-tap
formula: <cli-name>
tag: v${{ steps.release.outputs.new_release_version }}Language::Node::Shebang and a resource block; the bump action does not handle that shape.Justintime50/homebrew-releaser, keep that standard action. Use custom shell only after proving no maintained action fits.Formula/. Homebrew also accepts repo root, but Formula/ scales when you add more CLIs.brew audit --strict --online Formula/*.rb on PR. Catches malformed formulae before they break user installs.main.[skip ci] semantics apply there too if the tap repo has its own CI.A composite or JS action is "published" by tagging — the marketplace pulls from tags. No registry push.
Plugins:
"@semantic-release/git",
"@semantic-release/github"The git plugin commits the bump (typically just package.json for a JS action) so consumers pinning to @v1 follow the moving major tag.
For a moving major tag (@v1 always pointing at the latest v1.x.y), use a maintained semantic-release plugin or a tiny repo-owned release action. Do not paste tag parsing and force-push shell into workflow YAML.
- if: steps.release.outputs.new_release_published == 'true'
uses: ./.github/actions/update-major-action-tag
with:
version: ${{ steps.release.outputs.new_release_version }}The local action should do one thing: update v<major> to the release commit and push it with the release token. If a maintained semantic-release major-tag plugin fits the repo, prefer that.
The action's action.yml runs: block must reference the bundled entrypoint (dist/index.js), not a TS source file. Build it in the verify path and either commit dist/ or rebuild in the release job.
One semantic-release run per package, each with its own .releaserc.json and tag prefix:
{
"tagFormat": "<package-name>-v${version}",
"branches": ["main"]
}Workflow:
- uses: cycjimmy/semantic-release-action@<full-sha> # v6.0.0
with:
working_directory: packages/<package-name>