github-workflows
Diagnose, fix, and optimize GitHub Actions workflows for Rust projects. Use when setting up CI/CD, troubleshooting workflow failures, optimizing build times with caching, or ensuring best practices fo
github
workflows
SKILL.md.claude/skills/github-workflows
GitHubGitHub Workflows
Diagnose, fix, and optimize GitHub Actions workflows for Rust projects.
Quick Reference
- Caching Strategies - Manual cache, rust-cache, sccache
- Troubleshooting - Common issues, debugging, fixes
- Advanced Features - Coverage, security, benchmarking
- Release Management - Automated releases, versioning
When to Use
- Setting up CI/CD for Rust projects
- Troubleshooting workflow failures
- Optimizing build times with caching
- Ensuring best practices for testing, linting, releases
Before Making Changes
ALWAYS verify current state first:
# Get repo info
gh repo view --json nameWithOwner,owner,name
# List existing workflows
gh workflow list
# Check recent runs
gh run list --limit 10
# View workflow files
ls -la .github/workflows/
Complete Rust CI Workflow
See the full workflow template with:
- Check job (format, clippy, check)
- Test job (unit, integration, doc tests)
- Coverage job (tarpaulin, codecov)
- Audit job (security, licenses)
See linked files for caching strategies, troubleshooting, and release management.
Core Workflow Components
| Job | Purpose | Tools |
|---|---|---|
| check | Code quality | rustfmt, clippy, cargo check |
| test | Verification | cargo test |
| coverage | Test metrics | cargo tarpaulin |
| audit | Security | cargo audit, deny |
Common Patterns
- Caching: Dependencies, target directory, sccache
- Matrix builds: Multiple Rust versions, targets
- Conditional jobs: Skip on docs-only changes
- Quality gates: Block merge on failures
Toolchain Pinning Guardrail
- Never float a toolchain in a workflow that gates work or runs on a schedule.
nightlybroke a transitive dependency (branches 0.4.5,core::intrinsics) with no repository change and failed every fuzz target as a startup failure; floatingstablesimilarly adds deny-by-default lints between releases. - Pin a dated channel (
nightly-2026-09-26) in both the job env and thedtolnay/rust-toolchainaction input, comment why, and validate the pin by building the affected crate with that toolchain before pushing (LESSON-040).
Composite Input Truthiness Guardrail
Composite-action (
action.yml) inputs arrive as strings, even when the caller passes a rendered boolean, and GitHub coerces any non-empty string totrue— so!"false"isfalse.if: ${{ !inputs.dry-run }}therefore skips the step on every real run while the job reports success (LESSON-053). Blast radius, measured from the crates.io index on 2026-10-16:do-memory-core/-storage-redb/-storage-tursowere last published at 0.1.34 anddo-memory-mcpat 0.1.31, while tags ran to v0.1.45 — i.e. roughly a dozen green releases published nothing, not one or two.Match the wire type at the place where you compare, and normalise when the value crosses a boundary: a
workflow_dispatchinput declaredtype: booleanis a real boolean in the calling workflow'sif:, while a composite action input declared withouttype:(.github/actions/publish-crate/action.yml:15,default: "false") arrives as a string — and any non-empty string is truthy in theif:expression language, so!inputs.dry-runis!"false"=falseand the step silently never runs:# .github/actions/publish-crate/action.yml — the real publish step (line 197 at v0.1.45) - if: ${{ !inputs.dry-run && steps.check-version.outputs.exists != 'true' }} # after (#1179): negate the string form, like the two correct siblings already did + if: ${{ inputs.dry-run != 'true' && steps.check-version.outputs.exists != 'true' }}The workflow-level guard
(inputs.dry-run == true || github.ref_type == 'tag')(publish-crates.yml:47) was correct all along and is not the bug — which is precisely why the chain stayed green: the outer guard passed, the inner step skipped. Tag-only authority + OIDC still applies (ADR-072/ADR-078, never a static token).Add the per-crate selector (
inputs.crate == '' || inputs.crate == '<name>') and keep one comparison form across all steps of the action: the broken file compared correctly on two steps and negated on the third, which is why the skip survived review. PreferfromJSON()(or rendering the boolean once into a step output) over hand-matching stringified booleans.Assert the guard's type with a fixture, not with a re-read: extend
scripts/test-release-workflow.sh --publish-fixtures(it already fails the chain for a missing bounded poll or verification step) with a check for the exact comparison form, and letscripts/validate-gate-contract.shreject any!inputs.negation in the publish files. A guard nobody can grep is a guard that regresses.Green ≠ did: a job concludes
successwhile the step that performs the side effect isskipped. Read the step, not the rollup —gh run view <id> --json jobs -q '.jobs[].steps[] | select(.name == "Publish to crates.io") | .conclusion'andduration_ms=0on that step is a failed release even when CI is green.Put the proof inside the workflow: a final
Verify publish landedstep polls each crate's versions endpoint with the membership test below and::error::+exit 1s when the published version is missing. Keep the post-release external assert too (curl -sS -A ci-verify https://crates.io/api/v1/crates/<crate>/versions), never asleep-then-assume.Publish acceptance is exactly the four crates the chain owns (core → redb → turso → mcp).
do-memory-clianswering HTTP 404 is by design (it ships as a GitHub Release asset) anddo-memory-test-utilsispublish = false— neither is a publish failure, and both must be excluded from the expected set rather than "fixed" by widening the gate. Always send aUser-Agent: crates.io rejects the default curl UA withFETCH_FAILED-shaped noise that looks like an outage.A run executes the workflow and the composite actions from its own SHA: a fix only applies to runs dispatched from refs that contain it (dispatching on a tag uses the tag's copy, so a superseded tag keeps the old behaviour forever).
Prove side effects, do not assume them: any step that claims an external change (publish, push, register) must poll the provider with a bounded loop (
for i in $(seq 1 20),sleep 15) and::error::out when the object is missing. A green job is not evidence that an artifact exists.When checking whether an API already has an object, test membership of the full list (
[(.versions // [])[].num] | index($v) != null) rather than comparing the newest entry —[0]equality misreads a superseded-but-present version as absent and flips a retry into a hard failure.
PR Check Attachment Guardrail
- After pushing fixes, validate checks are attached to the PR head SHA:
gh pr view --json statusCheckRollup,mergeStateStatusgh run list --commit <head_sha>
- If rollup is empty for required checks, treat as blocked and investigate trigger/path conditions.
Trigger And Clock Guardrail
- Before explaining why a run happened, enumerate everything that can start it.
Read the whole
on:block — apushfilter includes tag patterns, sonightly-tests.yml(cron: 0 2 * * *pluspush: tags: ['v*.*.*']plusworkflow_dispatch) also runs on every release tag. Name the real cause withgh run view <id> --json event,triggerActor,runStartedAt, never by assuming the schedule. - Cron is not a clock. Queue delay pushed an
0 2 * * *start to 06:30Z and later, and a scheduled run re-attaches to whatever SHA is current (LESSON-051). Reason fromrunStartedAt/updatedTime, never from the cron expression. workflow_dispatchis the only reliable manual clock: to claim "the nightly ran", dispatch it and cite the run id —gh workflow run nightly-tests.yml -f run_slow_tests=truethengh run list --workflow nightly-tests.yml --event workflow_dispatch --limit 1.- The trigger list sets the price of an edit.
release-drift.ymluses[opened, synchronize, reopened, labeled, unlabeled]:editedis not a trigger, so PR body/title edits are free, while every branch push (synchronize) restarts the entire required suite. Batch docs/skills edits into one push instead of one push per file (LESSON-052/LESSON-051 churn). - A label applied with
GITHUB_TOKENdoes not re-trigger a run. When a check only fires onlabeled/synchronize, force it on a fresh head SHA (git commit --allow-empty -m "chore: re-trigger CI" && git push) — never by deleting runs, dropping a trigger, or weakening the gate to make it quiet.
Workflow Lint Guardrail
- Lint before pushing, on the same surface CI uses:
yamllint -c .yamllint.yml .github/,actionlint .github/workflows/*.yml, andshellcheckon everyrun:block you touched. yaml-lint.ymlinvokes actionlint withactionlint_flags: -shellcheck=— pointing the shellcheck path at an empty value disables actionlint's embedded shell analysis, so shell bugs in workflowrun:blocks pass CI green. Run shellcheck yourself; the fix is never to delete the lint job or silence rules.