The core principle: make releases fast and painless, and you'll release more often. This is critical for agile development - rapid, reliable deployments create the fast feedback loop you need. When deploying is scary or slow, teams batch up changes, increasing risk. When deploying is boring and automatic, you ship small changes frequently.
This guide covers the release strategies I use in production, from simple tag-based deployments to multi-package monorepo publishing. All examples come from real GitHub Actions workflows in this repository.
Before diving into details, here's how common strategies compare:
| Strategy | Best For | Complexity | Common In |
|---|---|---|---|
| Tag-based | Applications, explicit releases | Low | Startups, solo devs, mature teams |
| Branch-based | Continuous deployment | Low | Startups, fast-moving teams |
| GitFlow | Regulated releases, multiple versions | High | Enterprise, compliance-heavy |
| Trunk-based + Feature flags | High-velocity teams | Medium | Big tech, mature startups |
| Monorepo multi-package | Libraries, shared codebases | Medium | Platform teams, OSS projects |
Startups & Small Teams: Start with tag-based or branch-based deployment. Keep it simple - you can always add complexity later. Trunk-based development with feature flags is popular at scale but overkill for small teams.
Enterprise: Often requires GitFlow or similar for compliance, audit trails, and release management. Multiple environments (dev, QA, staging, prod) with approval gates. Artifact attestations increasingly required for supply chain security.
Solo Developers: Tag-based is ideal. Push a tag, deployment happens. No ceremony, no overhead.
Platform/Library Teams: Need monorepo strategies with independent versioning per package. Semantic versioning is critical for downstream consumers.
The simplest and most reliable strategy. Different tag prefixes route to different environments or trigger different actions.
on:
push:
tags:
- 'release-*' # Production
- 'local-*' # Dev environment
- name: Build Docker image
run: |
TAG_NAME=${GITHUB_REF#refs/tags/}
if [[ "$TAG_NAME" == local-* ]]; then
docker build -t myapp:local .
else
docker build -t myapp:latest -t myapp:$(date +%s) .
fi
Why it works: Simple mental model, explicit deployments, easy rollback via timestamped tags.
# Deploy to production
git tag release-v2024.12.01 && git push origin release-v2024.12.01
# Deploy to dev
git tag local-feature-test && git push origin local-feature-test
For monorepos with multiple publishable packages, use tag prefixes to identify which package to release:
# Different workflows, different tag patterns
# umami-net.yml
on:
push:
tags: ['umamiv*.*.*'] # e.g., umamiv1.0.5
# fetchextension.yml
on:
push:
tags: ['fetchextension-v*.*.*'] # e.g., fetchextension-v1.2.0
Extract the version and use it throughout:
- name: Extract version
run: echo "VERSION=${GITHUB_REF#refs/tags/umamiv}" >> $GITHUB_OUTPUT
- name: Build & Pack
run: |
dotnet build -c Release -p:Version=${{ steps.version.outputs.VERSION }}
dotnet pack -c Release -p:PackageVersion=${{ steps.version.outputs.VERSION }}
For automatic versioning, MinVer calculates versions from Git tags:
<PackageReference Include="MinVer" Version="6.0.0" PrivateAssets="all" />
<PropertyGroup>
<MinVerTagPrefix>umamiv</MinVerTagPrefix>
</PropertyGroup>
Deploy automatically when code lands on specific branches. Common in startups and fast-moving teams.
on:
push:
branches: [main] # → Production
# branches: [develop] # → Staging
Pros: No manual step, deploys on merge Cons: Accidental deploys possible, less explicit history
I use a hybrid approach - build on branch push (CI verification), but only publish on tags:
on:
push:
tags: ['scheduler-*']
branches: [main, local]
jobs:
build:
# Always build
publish:
if: startsWith(github.ref, 'refs/tags/') # Only publish on tags
For self-hosted deployments, Watchtower automatically pulls new images:
services:
app:
image: myapp:latest
labels:
- "com.centurylinklabs.watchtower.enable=true"
watchtower:
image: containrrr/watchtower
volumes:
- /var/run/docker.sock:/var/run/docker.sock
command: --interval 300 # Check every 5 minutes
Deployment flow: Push tag → GitHub Actions builds → Push to registry → Watchtower pulls → Container restarts. Total time: ~5 minutes.
Fast deployments are useless if you deploy bugs. Every workflow should include gates:
- name: Run tests
run: dotnet test --configuration Release
- name: Build
run: dotnet build --configuration Release
# Tests or build fail → workflow stops → no publish
For multi-framework packages, build all targets:
- run: dotnet build -c Release --framework net8.0
- run: dotnet build -c Release --framework net9.0
Modern approach - no secrets to rotate, no keys to leak:
permissions:
id-token: write
contents: read
- uses: NuGet/login@v1
with:
user: 'myusername'
- run: dotnet nuget push *.nupkg --api-key ${{ steps.login.outputs.NUGET_API_KEY }}
GitHub exchanges its OIDC token for a short-lived NuGet API key. Configure trust on NuGet.org to enable this.
Artifact attestations prove your artifacts were built in CI, not tampered with. Increasingly required by enterprises.
permissions:
id-token: write
attestations: write
- uses: docker/build-push-action@v6
id: push
with:
push: true
tags: myapp:latest
- uses: actions/attest-build-provenance@v2
with:
subject-name: index.docker.io/myuser/myapp
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true
Consumers verify with: gh attestation verify oci://index.docker.io/myuser/myapp:latest --owner myuser
This achieves SLSA Level 2. For Level 3, use reusable workflows.
Teams need stakeholders to preview changes before merging.
Enterprise approach - Branch-based preview environments:
on:
pull_request:
types: [opened, synchronize]
- run: |
BRANCH=$(echo ${{ github.head_ref }} | sed 's/[^a-zA-Z0-9]/-/g')
docker build -t myapp:preview-$BRANCH .
# Deploy to k8s namespace, cloud platform, etc.
Startup/solo approach - Tunnel your local machine:
# Cloudflare Tunnel (free)
cloudflared tunnel run --url http://localhost:5000 my-preview
# → https://my-preview.cfargotunnel.com
Or use Wireguard VPN for internal team access to your dev machine.
Tag cleanup: Git tags stick around forever.
git tag -d old-test-tag # Delete local
git push origin :refs/tags/old-test-tag # Delete remote
Naming conventions: Be consistent.
release-YYYY.MM.DD for productionlocal-feature-name for devpackagev1.2.3 for librariesSecrets: Store in GitHub Secrets, never in code. Required secrets typically:
DOCKER_HUB_ACCESS_TOKENNUGET_API_KEY (or use OIDC)NPM_TOKENMonorepo development: Use project references during development, switch to package references for deployment verification:
<ProjectReference Include="..\MyLib\MyLib.csproj" />
<!-- <PackageReference Include="MyLib" Version="1.0.0" /> -->
Same principles, different tools:
| Docker Compose | Kubernetes |
|---|---|
| Watchtower | ArgoCD / Flux |
| docker-compose.yml environments | Namespaces |
| Container restart | Rolling deployment |
| Manual rollback | GitOps rollback |
The workflows shown here run in this repository - check .github/workflows/ for complete implementations.
Remember: Rapid, reliable deployments are the foundation of agile development. Invest in your release pipeline early.
© 2025 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.