26 KiB
CI/CD Pipeline Guide
Load this guide when: Adding Docker build/push steps, configuring Woodpecker CI pipelines, publishing packages to registries, or implementing CI/CD for a new project.
Overview
This guide covers the canonical CI/CD pattern used across projects. The pipeline runs in Woodpecker CI and follows this flow:
GIT PUSH
↓
QUALITY GATES (lint, typecheck, test, audit)
↓ all pass
BUILD (compile all packages)
↓ only on main/develop/tags
DOCKER BUILD & PUSH (Kaniko → Gitea Container Registry)
↓ all images pushed
PACKAGE LINKING (associate images with repository in Gitea)
Reference Implementations
Split Pipelines (Preferred for Monorepos)
Mosaic Telemetry (~/src/mosaic-telemetry-monorepo/.woodpecker/) is the canonical example of split per-package pipelines with path filtering, full security chain (source + container scanning), and efficient CI resource usage.
Key features:
- One YAML per package in
.woodpecker/directory - Path filtering: only the affected package's pipeline runs on push
- Security chain: source scanning (bandit/npm audit) + dependency audit (pip-audit) + container scanning (Trivy)
- Docker build gates on ALL quality steps
Always use this pattern for monorepos. It saves CI minutes and isolates failures.
Single Pipeline (Legacy/Simple Projects)
Mosaic Stack (~/src/mosaic-stack/.woodpecker/build.yml) uses a single pipeline that builds everything on every push. This works but wastes CI resources on large monorepos. Mosaic Stack is scheduled for migration to split pipelines.
Always read the telemetry pipelines first when implementing a new pipeline.
Infrastructure Instances
| Project | Gitea | Woodpecker | Registry |
|---|---|---|---|
| Mosaic Stack | git.mosaicstack.dev |
ci.mosaicstack.dev |
git.mosaicstack.dev |
| U-Connect | git.uscllc.com |
woodpecker.uscllc.net |
git.uscllc.com |
The patterns are identical — only the hostnames and org/repo names differ.
Woodpecker Pipeline Structure
YAML Anchors (DRY)
Define reusable values at the top of .woodpecker.yml:
variables:
- &node_image "node:20-alpine"
- &install_deps |
corepack enable
npm ci
# For pnpm projects, use:
# - &install_deps |
# corepack enable
# pnpm install --frozen-lockfile
- &kaniko_setup |
mkdir -p /kaniko/.docker
echo "{\"auths\":{\"REGISTRY_HOST\":{\"username\":\"$GITEA_USER\",\"password\":\"$GITEA_TOKEN\"}}}" > /kaniko/.docker/config.json
Replace REGISTRY_HOST with the actual Gitea hostname (e.g., git.uscllc.com).
Step Dependencies
Woodpecker runs steps in parallel by default. Use depends_on to create the dependency graph:
steps:
install:
image: *node_image
commands:
- *install_deps
lint:
image: *node_image
commands:
- npm run lint
depends_on:
- install
typecheck:
image: *node_image
commands:
- npm run type-check
depends_on:
- install
test:
image: *node_image
commands:
- npm run test
depends_on:
- install
build:
image: *node_image
environment:
NODE_ENV: "production"
commands:
- npm run build
depends_on:
- lint
- typecheck
- test
Conditional Execution
Use when clauses to limit expensive steps (Docker builds) to relevant branches:
when:
# Top-level: run quality gates on everything
- event: [push, pull_request, manual]
# Per-step: only build Docker images on main/develop/tags
docker-build-api:
when:
- branch: [main, develop]
event: [push, manual, tag]
Docker Build & Push with Kaniko
Why Kaniko
Kaniko builds container images without requiring a Docker daemon. This is the standard approach in Woodpecker CI because:
- No privileged mode needed
- No Docker-in-Docker security concerns
- Multi-destination tagging in a single build
- Works in any container runtime
Kaniko Step Template
docker-build-SERVICE:
image: gcr.io/kaniko-project/executor:debug
environment:
GITEA_USER:
from_secret: gitea_username
GITEA_TOKEN:
from_secret: gitea_token
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
commands:
- *kaniko_setup
- |
DESTINATIONS="--destination REGISTRY/ORG/IMAGE_NAME:${CI_COMMIT_SHA:0:8}"
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="$DESTINATIONS --destination REGISTRY/ORG/IMAGE_NAME:latest"
elif [ "$CI_COMMIT_BRANCH" = "develop" ]; then
DESTINATIONS="$DESTINATIONS --destination REGISTRY/ORG/IMAGE_NAME:dev"
fi
if [ -n "$CI_COMMIT_TAG" ]; then
DESTINATIONS="$DESTINATIONS --destination REGISTRY/ORG/IMAGE_NAME:$CI_COMMIT_TAG"
fi
/kaniko/executor --context . --dockerfile PATH/TO/Dockerfile $DESTINATIONS
when:
- branch: [main, develop]
event: [push, manual, tag]
depends_on:
- build
Replace these placeholders:
| Placeholder | Example (Mosaic) | Example (U-Connect) |
|---|---|---|
REGISTRY |
git.mosaicstack.dev |
git.uscllc.com |
ORG |
mosaic |
usc |
IMAGE_NAME |
stack-api |
uconnect-backend-api |
PATH/TO/Dockerfile |
apps/api/Dockerfile |
src/backend-api/Dockerfile |
Image Tagging Strategy
Every build produces multiple tags:
| Condition | Tag | Purpose |
|---|---|---|
| Always | ${CI_COMMIT_SHA:0:8} |
Immutable reference to exact commit |
main branch |
latest |
Current production release |
develop branch |
dev |
Current development build |
Git tag (e.g., v1.0.0) |
v1.0.0 |
Semantic version release |
Kaniko Options
Common flags for /kaniko/executor:
| Flag | Purpose |
|---|---|
--context . |
Build context directory |
--dockerfile path/Dockerfile |
Dockerfile location |
--destination registry/org/image:tag |
Push target (repeatable) |
--build-arg KEY=VALUE |
Pass build arguments |
--cache=true |
Enable layer caching |
--cache-repo registry/org/image-cache |
Cache storage location |
Build Arguments
Pass environment-specific values at build time:
/kaniko/executor --context . --dockerfile apps/web/Dockerfile \
--build-arg NEXT_PUBLIC_API_URL=https://api.example.com \
$DESTINATIONS
Gitea Container Registry
How It Works
Gitea has a built-in container registry. When you push an image to git.example.com/org/image:tag, Gitea stores it and makes it available in the Packages section.
Authentication
Kaniko authenticates via a Docker config file created at pipeline start:
{
"auths": {
"git.example.com": {
"username": "GITEA_USER",
"password": "GITEA_TOKEN"
}
}
}
The token must have package:write scope. Generate it at: https://GITEA_HOST/user/settings/applications
Pulling Images
After pushing, images are available at:
docker pull git.example.com/org/image:tag
In docker-compose.yml:
services:
api:
image: git.example.com/org/image:${IMAGE_TAG:-dev}
Package Linking
After pushing images to the Gitea registry, link them to the source repository so they appear on the repository's Packages tab.
Gitea Package Linking API
POST /api/v1/packages/{owner}/{type}/{name}/-/link/{repo}
| Parameter | Value |
|---|---|
owner |
Organization name (e.g., mosaic, usc) |
type |
container |
name |
Image name (e.g., stack-api) |
repo |
Repository name (e.g., stack, uconnect) |
Link Step Template
link-packages:
image: alpine:3
environment:
GITEA_TOKEN:
from_secret: gitea_token
commands:
- apk add --no-cache curl
- echo "Waiting 10 seconds for packages to be indexed..."
- sleep 10
- |
set -e
link_package() {
PKG="$$1"
echo "Linking $$PKG..."
for attempt in 1 2 3; do
STATUS=$$(curl -s -o /tmp/link-response.txt -w "%{http_code}" -X POST \
-H "Authorization: token $$GITEA_TOKEN" \
"https://GITEA_HOST/api/v1/packages/ORG/container/$$PKG/-/link/REPO")
if [ "$$STATUS" = "201" ] || [ "$$STATUS" = "204" ]; then
echo " Linked $$PKG"
return 0
elif [ "$$STATUS" = "400" ]; then
echo " $$PKG already linked"
return 0
elif [ "$$STATUS" = "404" ] && [ $$attempt -lt 3 ]; then
echo " $$PKG not found yet, retrying in 5s (attempt $$attempt/3)..."
sleep 5
else
echo " FAILED: $$PKG status $$STATUS"
cat /tmp/link-response.txt
return 1
fi
done
}
link_package "image-name-1"
link_package "image-name-2"
when:
- branch: [main, develop]
event: [push, manual, tag]
depends_on:
- docker-build-image-1
- docker-build-image-2
Replace: GITEA_HOST, ORG, REPO, and the link_package calls with actual image names.
Note on $$: Woodpecker uses $$ to escape $ in shell commands within YAML. Use $$ for shell variables and ${CI_*} (single $) for Woodpecker CI variables.
Status Codes
| Code | Meaning | Action |
|---|---|---|
| 201 | Created | Success |
| 204 | No content | Success |
| 400 | Bad request | Already linked (OK) |
| 404 | Not found | Retry — package may not be indexed yet |
Known Issue
The Gitea package linking API (added in Gitea 1.24.0) can return 404 for recently pushed packages. The retry logic with 5-second delays handles this. If linking still fails, packages are usable — they just won't appear on the repository Packages tab. They can be linked manually via the Gitea web UI.
Woodpecker Secrets
Required Secrets
Configure these in the Woodpecker UI (Settings > Secrets) or via CLI:
| Secret Name | Value | Scope |
|---|---|---|
gitea_username |
Gitea username or service account | push, manual, tag |
gitea_token |
Gitea token with package:write scope |
push, manual, tag |
Setting Secrets via CLI
# Woodpecker CLI
woodpecker secret add ORG/REPO --name gitea_username --value "USERNAME"
woodpecker secret add ORG/REPO --name gitea_token --value "TOKEN"
Security Rules
- Never hardcode tokens in pipeline YAML
- Use
from_secretfor all credentials - Limit secret event scope (don't expose on
pull_requestfrom forks) - Use dedicated service accounts, not personal tokens
- Rotate tokens periodically
npm Package Publishing
For projects with publishable npm packages (e.g., shared libraries, design systems).
Publishing to Gitea npm Registry
Gitea includes a built-in npm registry at https://GITEA_HOST/api/packages/ORG/npm/.
Pipeline step:
publish-packages:
image: *node_image
environment:
GITEA_TOKEN:
from_secret: gitea_token
commands:
- |
echo "//GITEA_HOST/api/packages/ORG/npm/:_authToken=$$GITEA_TOKEN" > .npmrc
echo "@SCOPE:registry=https://GITEA_HOST/api/packages/ORG/npm/" >> .npmrc
- npm publish -w @SCOPE/package-name
when:
- branch: [main]
event: [push, manual, tag]
depends_on:
- build
Replace: GITEA_HOST, ORG, SCOPE, package-name.
Why Gitea npm (not Verdaccio)
Gitea's built-in npm registry eliminates the need for a separate Verdaccio instance. Benefits:
- Same auth — Gitea token with
package:writescope works for git, containers, AND npm - No extra service — No Verdaccio container, no OAuth/Authentik integration, no separate compose stack
- Same UI — Packages appear alongside container images in Gitea's Packages tab
- Same secrets —
gitea_tokenin Woodpecker handles both Docker push and npm publish
If a project currently uses Verdaccio (e.g., U-Connect at npm.uscllc.net), migrate to Gitea npm. See the migration checklist below.
Versioning
Only publish when the version in package.json has changed. Add a version check:
commands:
- |
CURRENT=$(node -p "require('./src/PACKAGE/package.json').version")
PUBLISHED=$(npm view @SCOPE/PACKAGE version 2>/dev/null || echo "0.0.0")
if [ "$CURRENT" = "$PUBLISHED" ]; then
echo "Version $CURRENT already published, skipping"
exit 0
fi
echo "Publishing $CURRENT (was $PUBLISHED)"
npm publish -w @SCOPE/PACKAGE
CI Services (Test Databases)
For projects that need a database during CI (migrations, integration tests):
services:
postgres:
image: postgres:17-alpine
environment:
POSTGRES_DB: test_db
POSTGRES_USER: test_user
POSTGRES_PASSWORD: test_password
steps:
test:
image: *node_image
environment:
DATABASE_URL: "postgresql://test_user:test_password@postgres:5432/test_db?schema=public"
commands:
- npm run test
depends_on:
- install
The service name (postgres) becomes the hostname within the pipeline network.
Split Pipelines for Monorepos (REQUIRED)
For any monorepo with multiple packages/apps, use split pipelines — one YAML per package in .woodpecker/.
Why Split?
| Aspect | Single pipeline | Split pipelines |
|---|---|---|
| Path filtering | None — everything rebuilds | Per-package — only affected code |
| Security scanning | Often missing | Required per-package |
| CI minutes | Wasted on unaffected packages | Efficient |
| Failure isolation | One failure blocks everything | Per-package failures isolated |
| Readability | One massive file | Focused, maintainable |
Structure
.woodpecker/
├── api.yml # Only runs when apps/api/** changes
├── web.yml # Only runs when apps/web/** changes
└── (infra.yml) # Optional: shared infra (DB images, etc.)
IMPORTANT: Do NOT also have .woodpecker.yml at root — .woodpecker/ directory takes precedence and the .yml file will be silently ignored.
Path Filtering Template
when:
- event: [push, pull_request, manual]
path:
include: ['apps/api/**', '.woodpecker/api.yml']
Each pipeline self-triggers on its own YAML changes. Manual triggers run regardless of path.
Kaniko Context Scoping
In split pipelines, scope the Kaniko context to the app directory:
/kaniko/executor --context apps/api --dockerfile apps/api/Dockerfile $$DESTINATIONS
This means Dockerfile COPY . . only copies the app's files, not the entire monorepo.
Reference: Telemetry Split Pipeline
See ~/src/mosaic-telemetry-monorepo/.woodpecker/api.yml and web.yml for a complete working example with path filtering, security chain, and Trivy scanning.
Security Scanning (REQUIRED)
Every pipeline MUST include security scanning. Docker build steps MUST gate on all security steps passing.
Source-Level Security (per tech stack)
Python:
security-bandit:
image: *uv_image
commands:
- |
cd apps/api
uv sync --all-extras --frozen
uv run bandit -r src/ -f screen
depends_on: [install]
security-audit:
image: *uv_image
commands:
- |
cd apps/api
uv sync --all-extras --frozen
uv run pip-audit
depends_on: [install]
Node.js:
security-audit:
image: node:22-alpine
commands:
- cd apps/web && npm audit --audit-level=high
depends_on: [install]
Container Scanning (Trivy) — Post-Build
Run Trivy against every built image to catch OS-level and runtime vulnerabilities:
security-trivy:
image: aquasec/trivy:latest
environment:
GITEA_USER:
from_secret: gitea_username
GITEA_TOKEN:
from_secret: gitea_token
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
commands:
- |
mkdir -p ~/.docker
echo "{\"auths\":{\"REGISTRY\":{\"username\":\"$$GITEA_USER\",\"password\":\"$$GITEA_TOKEN\"}}}" > ~/.docker/config.json
trivy image --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed \
REGISTRY/ORG/IMAGE:$${CI_COMMIT_SHA:0:8}
when:
- branch: [main, develop]
event: [push, manual, tag]
depends_on:
- docker-build-SERVICE
Replace: REGISTRY, ORG, IMAGE, SERVICE.
Full Dependency Chain
install → [lint, typecheck, security-source, security-deps, test] → docker-build → trivy → link-package
Docker build MUST depend on ALL quality + security steps. Trivy runs AFTER build. Package linking runs AFTER Trivy.
Monorepo Considerations
pnpm + Turbo (Mosaic Stack pattern)
variables:
- &install_deps |
corepack enable
pnpm install --frozen-lockfile
steps:
build:
commands:
- *install_deps
- pnpm build # Turbo handles dependency order and caching
npm Workspaces (U-Connect pattern)
variables:
- &install_deps |
corepack enable
npm ci
steps:
# Build shared dependencies first
build-deps:
commands:
- npm run build -w @scope/shared-auth
- npm run build -w @scope/shared-types
# Then build everything
build-all:
commands:
- npm run build -w @scope/package-1
- npm run build -w @scope/package-2
# ... in dependency order
depends_on:
- build-deps
Per-Package Quality Checks
For large monorepos, run checks per-package in parallel:
lint-api:
commands:
- npm run lint -w @scope/api
depends_on: [install]
lint-web:
commands:
- npm run lint -w @scope/web
depends_on: [install]
# These run in parallel since they share the same dependency
Complete Pipeline Example
This is a minimal but complete pipeline for a project with two services:
when:
- event: [push, pull_request, manual]
variables:
- &node_image "node:20-alpine"
- &install_deps |
corepack enable
npm ci
- &kaniko_setup |
mkdir -p /kaniko/.docker
echo "{\"auths\":{\"git.example.com\":{\"username\":\"$GITEA_USER\",\"password\":\"$GITEA_TOKEN\"}}}" > /kaniko/.docker/config.json
steps:
# === Quality Gates ===
install:
image: *node_image
commands:
- *install_deps
lint:
image: *node_image
commands:
- npm run lint
depends_on: [install]
test:
image: *node_image
commands:
- npm run test
depends_on: [install]
build:
image: *node_image
environment:
NODE_ENV: "production"
commands:
- npm run build
depends_on: [lint, test]
# === Docker Build & Push ===
docker-build-api:
image: gcr.io/kaniko-project/executor:debug
environment:
GITEA_USER:
from_secret: gitea_username
GITEA_TOKEN:
from_secret: gitea_token
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
commands:
- *kaniko_setup
- |
DESTINATIONS="--destination git.example.com/org/api:${CI_COMMIT_SHA:0:8}"
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="$DESTINATIONS --destination git.example.com/org/api:latest"
elif [ "$CI_COMMIT_BRANCH" = "develop" ]; then
DESTINATIONS="$DESTINATIONS --destination git.example.com/org/api:dev"
fi
if [ -n "$CI_COMMIT_TAG" ]; then
DESTINATIONS="$DESTINATIONS --destination git.example.com/org/api:$CI_COMMIT_TAG"
fi
/kaniko/executor --context . --dockerfile src/api/Dockerfile $DESTINATIONS
when:
- branch: [main, develop]
event: [push, manual, tag]
depends_on: [build]
docker-build-web:
image: gcr.io/kaniko-project/executor:debug
environment:
GITEA_USER:
from_secret: gitea_username
GITEA_TOKEN:
from_secret: gitea_token
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
commands:
- *kaniko_setup
- |
DESTINATIONS="--destination git.example.com/org/web:${CI_COMMIT_SHA:0:8}"
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="$DESTINATIONS --destination git.example.com/org/web:latest"
elif [ "$CI_COMMIT_BRANCH" = "develop" ]; then
DESTINATIONS="$DESTINATIONS --destination git.example.com/org/web:dev"
fi
if [ -n "$CI_COMMIT_TAG" ]; then
DESTINATIONS="$DESTINATIONS --destination git.example.com/org/web:$CI_COMMIT_TAG"
fi
/kaniko/executor --context . --dockerfile src/web/Dockerfile $DESTINATIONS
when:
- branch: [main, develop]
event: [push, manual, tag]
depends_on: [build]
# === Package Linking ===
link-packages:
image: alpine:3
environment:
GITEA_TOKEN:
from_secret: gitea_token
commands:
- apk add --no-cache curl
- sleep 10
- |
set -e
link_package() {
PKG="$$1"
for attempt in 1 2 3; do
STATUS=$$(curl -s -o /dev/null -w "%{http_code}" -X POST \
-H "Authorization: token $$GITEA_TOKEN" \
"https://git.example.com/api/v1/packages/org/container/$$PKG/-/link/repo")
if [ "$$STATUS" = "201" ] || [ "$$STATUS" = "204" ] || [ "$$STATUS" = "400" ]; then
echo "Linked $$PKG ($$STATUS)"
return 0
elif [ $$attempt -lt 3 ]; then
sleep 5
else
echo "FAILED: $$PKG ($$STATUS)"
return 1
fi
done
}
link_package "api"
link_package "web"
when:
- branch: [main, develop]
event: [push, manual, tag]
depends_on:
- docker-build-api
- docker-build-web
Checklist: Adding CI/CD to a Project
- Verify Dockerfiles exist for each service that needs an image
- Create Woodpecker secrets (
gitea_username,gitea_token) in the Woodpecker UI - Verify Gitea token scope includes
package:write - Add Docker build steps to
.woodpecker.ymlusing the Kaniko template above - Add package linking step after all Docker builds
- Update
docker-compose.ymlto reference registry images instead of local builds:image: git.example.com/org/service:${IMAGE_TAG:-dev} - Test on develop branch first — push a small change and verify the pipeline
- Verify images appear in Gitea Packages tab after successful pipeline
Gitea as Unified Platform
Gitea provides three services in one, eliminating the need for separate Harbor and Verdaccio deployments:
| Service | What Gitea Replaces | Registry URL |
|---|---|---|
| Git hosting | GitHub/GitLab | https://GITEA_HOST/org/repo |
| Container registry | Harbor, Docker Hub | docker pull GITEA_HOST/org/image:tag |
| npm registry | Verdaccio, Artifactory | https://GITEA_HOST/api/packages/org/npm/ |
Additional Package Types
Gitea also supports PyPI, Maven, NuGet, Cargo, Composer, Conan, Conda, Generic, and more. All use the same token authentication.
Single Token, Three Services
A Gitea token with package:write scope handles:
git push/git pulldocker push/docker pull(container registry)npm publish/npm install(npm registry)
This means a single gitea_token secret in Woodpecker CI covers all CI/CD operations.
Architecture Simplification
Before (3 services):
Gitea (git) + Harbor (containers) + Verdaccio (npm)
↓ separate auth ↓ separate auth ↓ OAuth/Authentik
3 tokens 1 robot account 1 OIDC integration
3 backup targets complex RBAC group-based access
After (1 service):
Gitea (git + containers + npm)
↓ single token
1 secret in Woodpecker
1 backup target
unified RBAC via Gitea teams
Migrating from Verdaccio to Gitea npm
If a project currently uses Verdaccio (e.g., U-Connect at npm.uscllc.net), follow this migration checklist:
Migration Steps
-
Verify Gitea npm registry is accessible:
curl -s https://GITEA_HOST/api/packages/ORG/npm/ | head -5 -
Update
.npmrcin project root:# Before (Verdaccio) @uconnect:registry=https://npm.uscllc.net # After (Gitea) @uconnect:registry=https://git.uscllc.com/api/packages/usc/npm/ -
Update CI pipeline — replace
npm_tokensecret withgitea_token:# Uses same token as Docker push — no extra secret needed echo "//GITEA_HOST/api/packages/ORG/npm/:_authToken=$$GITEA_TOKEN" > .npmrc -
Re-publish existing packages to Gitea registry:
# For each @scope/package npm publish -w @scope/package --registry https://GITEA_HOST/api/packages/ORG/npm/ -
Update consumer projects — any project that
npm installs from the old registry needs its.npmrcupdated -
Remove Verdaccio infrastructure:
- Docker compose stack (
compose.verdaccio.yml) - Authentik OAuth provider/blueprints
- Verdaccio config files
- DNS entry for
npm.uscllc.net(eventually)
- Docker compose stack (
What You Can Remove
| Component | Location | Purpose (was) |
|---|---|---|
| Verdaccio compose | compose.verdaccio.yml |
npm registry container |
| Verdaccio config | config/verdaccio/ |
Server configuration |
| Authentik blueprints | config/authentik/blueprints/*/verdaccio-* |
OAuth integration |
| Verdaccio scripts | scripts/verdaccio/ |
Blueprint application |
| OIDC env vars | .env |
AUTHENTIK_VERDACCIO_*, VERDACCIO_OPENID_* |
Troubleshooting
"unauthorized: authentication required"
- Verify
gitea_usernameandgitea_tokensecrets are set in Woodpecker - Verify the token has
package:writescope - Check the registry hostname in
kaniko_setupmatches the Gitea instance
Kaniko build fails with "error building image"
- Verify the Dockerfile path is correct relative to
--context - Check that multi-stage builds don't reference stages that don't exist
- Run
docker buildlocally first to verify the Dockerfile works
Package linking returns 404
- Normal for recently pushed packages — the retry logic handles this
- If persistent: verify the package name matches exactly (case-sensitive)
- Check Gitea version is 1.24.0+ (package linking API requirement)
Images not visible in Gitea Packages
- Linking may have failed — check the
link-packagesstep logs - Images are still usable via
docker pulleven without linking - Link manually: Gitea UI > Packages > Select package > Link to repository
Pipeline runs Docker builds on pull requests
- Verify
whenclause on Docker build steps restricts tobranch: [main, develop] - Pull requests should only run quality gates, not build/push images