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
Conventions for deploy workflow files. The common shape uses main.yml, verify.yml, and deploy.yml; add preview-specific workflows only when PR-driven preview deploys need their own lifecycle.
Start by reading the repo's existing workflow/action files and any same-org repo that deploys to the same host. Preserve proven composite actions, token names, and deploy scripts when the target matches. Marketplace examples are fallback material, not the first source of truth.
.github/
├── workflows/
│ ├── main.yml # push to main → detect → verify → e2e → deploy → summary
│ ├── deploy.yml # workflow_dispatch -> re-deploy a verified payload ref
│ └── verify.yml # pull_request + merge_group → verify only (no deploy)
└── actions/
├── setup-workspace/ # one place to bootstrap (Node, pnpm/vite+, cache)
├── assume-deploy-identity/ # optional OIDC/provider federation wrapper
└── deploy-<lane>/ # repo-owned provider-thin deploy primitive, often SSTComposite actions can be useful for repo-owned deploy primitives, but do not turn this skill into a host cookbook. Keep provider mechanics in SST, scripts, infrastructure code, or a small local action whose inputs are payload reference or path, environment, lane, immutable version, and explicit deploy config path.
# main.yml
on:
push:
branches: [main]
# verify.yml
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
merge_group:
# deploy.yml
on:
workflow_dispatch:
inputs:
ref: { type: string, required: true, description: "Verified git ref or SHA to promote" }
environment: { type: choice, options: [staging, production], required: true }
lane: { type: choice, options: [web, api], required: true }merge_group: covers the GitHub merge queue. Without it the queue blocks PRs that depend on green checks from this workflow.pull_request: { types: [...ready_for_review] } keeps draft PRs out of CI but picks them up the moment they're marked ready.push: to verify.yml — the verify gate runs inside main.yml for push events.inputs.ref, inputs.environment, and inputs.lane in a secretless step before checkout or secret loading. Prefer main, protected release tags, or exact SHAs with a matching durable payload.inputs.ref as its own trust boundary.environment/lane choice input over accepting free-form branch names.pull_request_target for any workflow that checks out, installs, builds, tests, packages, deploys, or otherwise executes project code. Keep fork and outsider code on pull_request with read-only credentials and no deploy secrets.Do not add inline shell or JavaScript blocks to satisfy a scanner. Use standard actions first; use repo-owned actions or scripts only when the repo has a real product-specific boundary.
actionlint, zizmor, secret scanners, CodeQL, and dependency scanners instead of bespoke grep/awk/YAML parsers.dorny/paths-filter, GitHub path filters, or the repo's existing monorepo tool entrypoint.aws-actions/configure-aws-credentials, docker/login-action, or provider-owned OIDC actions..github/actions/<name> or scripts/ci/<name> with a narrow contract and tests; call it from YAML with inputs and outputs.run: is fine for simple commands like make verify, vp test, docker buildx imagetools inspect, or calling a repo-owned script. It is not fine for multi-branch policy logic, hand-rolled ref parsing, secret scanning, or YAML analysis.Run standard scanners before adding repo-specific workflow guard scripts:
workflow-hardening:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@<full-sha> # v6.0.2
- run: actionlint
- uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6
with:
advanced-security: false
annotations: true
min-severity: medium
min-confidence: mediumactionlint for workflow syntax/expression checks and zizmor for GitHub Actions security issues.advanced-security: true and keep security-events: write; otherwise use annotations and omit security-events: write.actionlint through the repo's normal tool bootstrap or a pinned setup action; do not replace it with a hand-rolled YAML parser.Use this when humans need to exercise a same-repo PR branch in staging before merge.
on:
workflow_dispatch:
inputs:
ref:
type: string
required: true
description: "Same-repo branch or full SHA"
environment:
type: choice
options: [staging, production]
required: true
jobs:
validate-ref:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
sha: ${{ steps.resolve.outputs.sha }}
environment: ${{ steps.resolve.outputs.environment }}
steps:
- uses: actions/checkout@<full-sha> # v6.0.2
with:
fetch-depth: 0
- id: resolve
uses: ./.github/actions/resolve-deploy-ref
with:
ref: ${{ inputs.ref }}
environment: ${{ inputs.environment }}
default-branch: ${{ github.event.repository.default_branch }}The local action should be small and covered by tests. If the repo does not already own that action or script, prefer a safer input model instead of pasting ref-parsing shell into the workflow.
Three different shapes, each tuned to the job's blast radius:
# verify / e2e — kill in-flight runs when the user pushes again
concurrency:
group: ${{ github.workflow }}-verify-${{ github.ref }}-${{ matrix.lane }}
cancel-in-progress: true
# deploy — serialize per (env, lane)
concurrency:
group: deploy-${{ inputs.environment || 'production' }}-${{ matrix.lane }}
cancel-in-progress: false
# top-level workflow guard for verify.yml only
concurrency:
group: verify-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}The deploy group must be the same key in main.yml and deploy.yml. That's how a manual re-deploy serializes against an in-flight push deploy. Mismatched keys -> both run, and whichever finishes second wins on the host but loses the provenance trail.
Optimize for short, predictable feedback without weakening the deploy contract.
paths or paths-ignore on workflows whose checks are required by branch protection. Keep the workflow scheduled, detect affected lanes inside it, and let a stable result job report success for docs-only or no-op changes.timeout-minutes on every non-trivial job. Start conservative: 10-15 for lint/typecheck, 20-30 for builds, 30-45 for e2e, and the smallest proven window for deploy.fail-fast: false when full lane/browser/platform evidence matters. Use max-parallel when external capacity is constrained by browser grids, preview environments, device farms, or provider rate limits.delivery-result or verify-result job that uses always() plus explicit needs.*.result checks. Make branch protection require that stable job, not every conditional lane.Default to least privilege at the workflow root, then re-grant per job:
permissions:
contents: read
jobs:
deploy-web:
permissions:
contents: read
id-token: write # provider OIDC / deploy federation / GitHub attestations
pull-requests: write # only if posting preview commentsid-token: write is required for OIDC-backed provider auth and keyless attestations.- uses: actions/checkout@<full-sha> # v6.0.2
with:
fetch-depth: 0 # required for paths-filter and turbo --affected
persist-credentials: false # add write credentials only at the exact push step, if one existsfetch-depth: 0 is non-negotiable for the changes job. Without git history, paths-filter cannot diff against the previous commit and turbo run --affected cannot resolve the merge base.
Keep checkout credentials unpersisted through install, build, e2e, package, and deploy setup. If a workflow truly must push a tag, comment, release note, or version bump, configure the narrow write token immediately before that step and do not expose it to dependency install or project-code execution.
Two patterns; pick by repo shape.
dorny/paths-filter (per-app rules, simple repos)- id: filter
uses: dorny/paths-filter@<full-sha> # v4.0.1
with:
filters: |
web:
- 'apps/web/**'
- 'packages/**'
- 'package.json'
- 'pnpm-lock.yaml'
api:
- 'apps/api/**'
- 'packages/**'
- 'Dockerfile'Always include the lockfile and shared packages/. A dep bump must rebuild every lane that consumes it.
--affected (monorepo with package graph)- id: detect
run: pnpm exec repo-detect-affected --format=github-outputTurbo follows the dependsOn graph; it catches transitive changes that a flat path-filter misses. Keep the JSON parsing and package-to-lane mapping inside a repo-owned CLI/script instead of inlining jq and grep in the workflow. Pair it with a "force lanes if these CI/hosting paths changed" rule (e.g. .github/workflows/**, Dockerfile, infrastructure/**) so a CI-only change still runs the full lane.
The same deploy payload must flow verify -> e2e -> deploy. Prefer the most durable payload boundary the repo already owns: a container image digest, package/release asset, provider-native deployment package, or same-job filesystem handoff for simple static deploys.
Do not reflexively add actions/upload-artifact / actions/download-artifact between build and deploy. GitHub Actions artifacts are quota- and retention-coupled CI storage. They are acceptable only as same-run scratch handoff when quota/retention are understood, but they are a weak default for production deploy or release promotion when a registry, GitHub Release asset, image digest, or provider-native package exists.
For a simple static app where build, e2e, and deploy can safely share one trusted job, keep the payload on disk and deploy after tests. Preserve security by loading deploy credentials only in the environment-scoped job. Do not add a separate curl-style smoke job unless the repo already owns meaningful synthetic checks.
Treat Actions artifacts as an exception, not the recipe. If a repo truly has no durable payload store and accepts same-run quota/retention coupling, document that tradeoff beside the workflow and keep artifact names unique per lane. Otherwise choose one of these shapes:
When same-run Actions artifacts are the accepted exception:
actions/upload-artifact with if-no-files-found: error.retention-days: 1, 2, or 3; do not keep deployment scratch artifacts for weeks.include-hidden-files: true unless the repo has explicitly audited hidden files for secrets.pull_request and privileged push: main, workflow_dispatch, or tag-driven jobs.deploy-web:
needs: [verify-web, e2e-web]
if: ${{ needs.verify-web.result == 'success' && needs.e2e-web.result == 'success' }}
deploy-summary:
needs: deploy-web
if: ${{ always() && needs.deploy-web.result == 'success' }}Use explicit result == 'success' checks for deploy gates. if: success() treats skipped upstream jobs as success when the lane was not affected, while always() && (...) belongs on final summary jobs.
For an SST-backed static or app surface, keep deploy provider-thin and let monitoring prove runtime health. The workflow should call a repo-owned deploy action or script; it should not inline provider command wiring:
- uses: ./.github/actions/deploy-surface
with:
payload-path: ${{ needs.e2e-web.outputs.payload_path }}
role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }}
aws-region: ${{ vars.AWS_REGION }}
role-session-name: deploy-web-${{ github.run_id }}
sst-config: infra/web/sst.config.tspayload-path before calling the action. For a simple static surface this may be the same job's build output; for a versioned release it may be a checked, unpacked release asset.sst-config explicitly per lane, for example infra/web/sst.config.ts.aws-actions/configure-aws-credentials; keep shell to direct repo commands such as vp exec -- sst ....prod and staging explicit SST stages/projects. Avoid one shared app/state with branch-dependent behavior unless state ownership and deletion boundaries are proven.Never interpolate ${{ inputs.* }} directly inside a run: block. Pass inputs through env: and let the shell quote normal variables:
runs:
using: composite
steps:
- shell: bash
env:
DIST_DIR: ${{ inputs.dist-dir }}
PROJECT_NAME: ${{ inputs.project-name }}
run: wrangler pages deploy "$DIST_DIR" --project-name "$PROJECT_NAME"zizmor catches this class of injection issue quickly; fix the shell shape instead of suppressing the finding.
For Node deploy workflows, check in .node-version with the latest active LTS line, currently Node 24.x. Use .node-version consistently across local setup and CI.
For a Vite+ workspace:
- uses: voidzero-dev/setup-vp@<full-sha> # v1.10.0
with:
version: ${{ env.VITE_PLUS_VERSION }}
node-version-file: .node-version
cache: true
- run: vp env currentFor a plain pnpm + Node workspace:
- uses: actions/setup-node@<full-sha> # v6.4.0
with:
node-version-file: .node-version
cache: pnpm
- uses: pnpm/action-setup@<full-sha> # v6.0.8
with: { run_install: false }
- run: pnpm install --frozen-lockfileFor a container build (api/backend lane), push to the repo's chosen registry with the narrowest write token or OIDC-supported identity available:
- uses: docker/setup-buildx-action@<full-sha> # v4.1.0
- uses: docker/login-action@<full-sha> # v4.2.0
with:
registry: ${{ vars.CONTAINER_REGISTRY }}
username: ${{ vars.CONTAINER_REGISTRY_USER }}
password: ${{ secrets.CONTAINER_REGISTRY_TOKEN }}
- uses: docker/build-push-action@<full-sha> # v7.2.0
with:
context: .
push: true
tags: |
${{ vars.CONTAINER_REGISTRY_IMAGE }}:${{ github.sha }}
${{ vars.CONTAINER_REGISTRY_IMAGE }}:main
cache-from: type=gha
cache-to: type=gha,mode=maxDeploy jobs consume the resulting immutable image digest or commit-SHA tag. They do not rebuild the image.
End every deploy run with a summary listing what shipped, which environment received it, where to inspect telemetry, which alerts or synthetic checks cover it, how to roll back, and which commit produced it. That summary is the human handoff during an incident, not the raw job log.
- run: scripts/ci/write-deploy-summary
env:
DEPLOY_ENVIRONMENT: production
DEPLOY_COMMIT: ${{ github.sha }}
WEB_RESULT: ${{ needs.deploy-web.result }}
API_RESULT: ${{ needs.deploy-api.result }}
MONITORING_URL: ${{ vars.PRODUCTION_MONITORING_URL }}
ROLLBACK_RUNBOOK_URL: ${{ vars.PRODUCTION_ROLLBACK_RUNBOOK_URL }}Keep formatting in a repo-owned script or local action once it is more than a couple of lines. Do not let incident handoff formatting turn into a large inline YAML block.