docs/CI_CD.md
Rama main · réplica de lectura del repositorio original.
# CI/CD Pipeline Documentation
## Overview
This document describes the comprehensive Continuous Integration and Continuous Deployment (CI/CD) pipeline for Actual-sync. The pipeline automates testing, building, Docker image publishing, security scanning, and release management.
## Table of Contents
- [Pipeline Architecture](#pipeline-architecture)
- [Dynamic Versioning](#dynamic-versioning)
- [Workflow Jobs](#workflow-jobs)
- [Setup Instructions](#setup-instructions)
- [Manual Triggers](#manual-triggers)
- [Docker Publishing](#docker-publishing)
- [Security Scanning](#security-scanning)
- [Troubleshooting](#troubleshooting)
---
## Pipeline Architecture
The CI/CD pipeline consists of 11 interconnected jobs that run in a specific order:
```
┌─────────────┐
│ Version │
│ Generation │
└──────┬──────┘
│
├────────────┬────────────┬──────────────┬────────────────────┐
▼ ▼ ▼ ▼ ▼
┌──────┐ ┌──────┐ ┌────────┐ ┌───────┐ ┌──────────────────┐
│ Lint │ │ Test │ │ E2E │ │ Build │ │ Validate Docker │
│ │ │ │ │ Tests │ │ │ │ Description │
└───┬──┘ └───┬──┘ └───┬────┘ └───┬───┘ └────────┬─────────┘
│ │ │ │ │
└───────────┴───────────┴─────────────┴──────────────────┘
│
▼
┌─────────────────┐
│ Docker Test │
│ Build │
└────────┬────────┘
│
┌───────────┴───────────┐
▼ ▼
┌──────────────┐ ┌──────────────┐
│ Docker │ │ Security │
│ Publish │ │ Scan │
└──────┬───────┘ └──────┬───────┘
│ │
├──────────────────────┘
│
▼
┌────────────────┐
│ Deployment │
│ Test │
└────────┬───────┘
│
▼ (on success on main)
┌──────────────────────────┐
│ Auto Release workflow │
│ (separate; patch-bump + │
│ tag + GitHub Release) │
└──────────────────────────┘
```
### Pipeline Triggers
The pipeline runs automatically on:
- **Push to main branch**: Full pipeline + Docker publish; on success triggers the separate Auto Release workflow (patch-bump + GitHub Release)
- **Push to develop branch**: Full pipeline with development tags
- **Pull requests**: Testing and validation only (no publishing)
- **Git tags (v*)**: Full pipeline with Docker publish for the version tag (the release itself is created by the Auto Release workflow, which also pushes the tag)
- **Manual dispatch**: Configurable workflow with custom options
**Docs-only changes are skipped.** Pushes/PRs that touch only `**/*.md`, `docs/**`, or `LICENSE` do not trigger the pipeline (`paths-ignore`), so documentation edits don't run builds/tests/publish — and a docs-only push to `main` does not fire the Auto Release.
**Chromium download is skipped everywhere except the E2E job.** The workflow sets `PUPPETEER_SKIP_DOWNLOAD=true`, so `npm ci` in the lint/test/build jobs does not download Puppeteer's ~170 MB Chromium. The `e2e` job (#263) overrides this back to `false` for itself, since it is the one job that launches a real browser; every other job, including the local screenshots script's normal usage outside CI, is unaffected.
---
## Dynamic Versioning
The pipeline uses a dynamic versioning system via `scripts/get_version.sh` that generates context-aware version strings.
### Version Format
```
<base_version>-<context>-<commit_hash>
```
### Version Examples
| Branch/Tag | Generated Version | Description |
|------------|------------------|-------------|
| `main` with tag `v1.2.3` | `1.2.3` | Production release |
| `main` without tag | `1.0.0-main-abc1234` | Main branch build |
| `develop` | `1.0.0-dev-abc1234` | Development build |
| `feature/auth` | `1.0.0-feature-auth-abc1234` | Feature branch |
| `bugfix/api-error` | `1.0.0-bugfix-api-error-abc1234` | Bug fix branch |
### How It Works
1. **Base Version**: Read from `package.json` version field
2. **Git Context**: Detect current branch name
3. **Commit Hash**: Short Git commit hash (7 characters)
4. **Tag Detection**: Check if commit is on an exact Git tag
5. **Sanitization**: Clean branch names (remove special characters)
### Version in Application
The version is available throughout the application:
```javascript
// Environment variable
process.env.VERSION
// Global variable
global.APP_VERSION
// Health endpoint
curl http://localhost:3000/health
{
"status": "UP",
"version": "1.0.0-dev-abc1234",
...
}
// Metrics endpoint
curl http://localhost:3000/metrics
{
"version": "1.0.0-dev-abc1234",
...
}
```
### Testing Version Script
```bash
# Test locally
./scripts/get_version.sh
# Verbose output with logs
./scripts/get_version.sh --verbose
# Expected output format
1.0.0-main-abc1234
```
---
## Workflow Jobs
### 1. Version Generation
**Purpose**: Generate dynamic version string for the entire pipeline
**Runs on**: All triggers
**Outputs**:
- `version`: Generated version string
- `is_release`: Boolean indicating if this is a release build
**Duration**: ~10 seconds
**Example Output**:
```
📦 Generated version: 1.0.0-dev-abc1234
🔨 This is a development build
```
---
### 2. Lint
**Purpose**: Validate code syntax and formatting
**Runs on**: All triggers
**Dependencies**: `version`
**Steps**:
1. Checkout code
2. Setup Node.js with caching
3. Install dependencies
4. Check JavaScript syntax
**Duration**: ~30 seconds
**What It Checks**:
- JavaScript syntax errors
- File accessibility
- Module dependencies
---
### 3. Test
**Purpose**: Run unit tests and collect coverage
**Runs on**: All triggers (unless `skip_tests=true`)
**Dependencies**: `version`
**Steps**:
1. Checkout code
2. Setup Node.js
3. Install dependencies
4. Run `npm audit --audit-level=high` (blocking: fails on any high or critical advisory)
5. Execute Jest tests with coverage
6. Upload coverage to Codecov
**Duration**: ~2-3 minutes
**Coverage Requirements** (defined in package.json):
- Branches: 70%
- Functions: 70%
- Lines: 70%
- Statements: 70%
**Test Reports**:
- Uploaded to Codecov
- Available in GitHub Actions summary
- LCOV format for detailed analysis
---
### 3b. E2E Tests (#263)
**Purpose**: Run the browser end-to-end suite (`tests/e2e/`) against a real
`HealthCheckService` and SQLite-backed history, driven through headless Chrome
**Runs on**: All triggers (unless `skip_tests=true`)
**Dependencies**: `version`
**Steps**:
1. Checkout code
2. Setup Node.js
3. Install dependencies (Chromium download included, unlike the other jobs -
see the Chromium note above)
4. Run `npm run test:e2e` (job `timeout-minutes: 15`; `~/.cache/puppeteer` is cached by `package-lock.json` hash so Chrome is not re-downloaded each run)
5. On failure, upload each failing spec's screenshot and console log from
`tests/e2e/artifacts/` (kept 7 days)
**Duration**: about 90 seconds
**What It Checks**: dashboard rendering, tab switching, manual sync, error
dismissal, and the general 60/min rate limiter (not the login failure
throttle), all against fixture states under
`tests/e2e/fixtures/states/` rather than a live sync target.
---
### 4. Build
**Purpose**: Build application and verify artifacts
**Runs on**: All triggers
**Dependencies**: `version`, `lint`, `test`, `e2e`
**Steps**:
1. Checkout code
2. Setup Node.js
3. Install production dependencies only
4. Verify build artifacts
**Duration**: ~45 seconds
**What It Verifies**:
- Production dependencies install correctly
- No missing modules
- Application structure is valid
---
### 5. Validate Docker Description
**Purpose**: Ensure Docker Hub short description meets length requirements
**Runs on**: All triggers
**Dependencies**: `version`
**Steps**:
1. Checkout code
2. Run validation script
3. Check character count ≤ 100
**Duration**: ~5 seconds
**Files Checked**:
- `docker/description/short.md`: Must be ≤ 100 characters
- Docker Hub enforces this limit strictly
**Current Status**: ✅ 97/100 characters (3 remaining)
---
### 6. Docker Test Build
**Purpose**: Test Docker build process without publishing
**Runs on**: All triggers
**Dependencies**: `version`, `build`, `validate-short-description`
**Steps**:
1. Checkout code
2. Setup Docker Buildx
3. Build Docker image locally
4. Test container startup
5. Verify health check
6. Hardened container smoke test (`scripts/docker-hardened-smoke.sh`, #253):
starts the image with `--cap-drop ALL`, the three capabilities
`docker-compose.yml` adds back (CHOWN, SETUID, SETGID),
`no-new-privileges` and a non-default PUID/PGID, then checks `/health`,
the dashboard, a recorded sync attempt in the SQLite history, the log
file, and that the Node process holds no capability
**Duration**: ~3-5 minutes
**What It Tests**:
- Dockerfile syntax and build process
- Multi-stage build completion
- Container starts successfully
- Health check endpoint responds
- Application logs show correct version
- The shipped compose hardening does not break the entrypoint or the
writable volumes (`src/__tests__/dockerCompose.test.js` keeps the script's
flags equal to the compose file's)
**Why This Matters**:
- Catches Docker build errors before publishing
- Validates VERSION build argument
- Ensures container runs properly
- Prevents broken images from being published
---
### 7. Docker Publish
**Purpose**: Build and publish Docker images to registries
**Runs on**: Push to `main` or `develop` branches, or tags
**Dependencies**: `version`, `docker-test`
**Condition**: Only if `skip_docker_publish != true`
**Steps**:
1. Checkout code
2. Setup QEMU for multi-architecture builds
3. Setup Docker Buildx
4. Login to Docker Hub
5. Login to GitHub Container Registry
6. Generate Docker tags
7. Build and push multi-platform images, with `provenance: mode=max` and `sbom: true`
8. Attest build provenance (Docker Hub and GHCR)
9. Attach the SBOM to the GitHub release (`v*` tags only)
10. Update Docker Hub description (main branch only)
**Duration**: ~10-15 minutes (multi-platform builds)
**SBOM and provenance (#252)**: the publish build asks BuildKit for an SPDX
SBOM and max-detail SLSA provenance, which it stores as attestation manifests
next to the image index in both registries. Two `actions/attest-build-provenance`
steps then sign a GitHub artifact attestation for the pushed digest, one per
registry, which `gh attestation verify` checks against GitHub's trust root. On a
`v*` tag the job also pulls the SBOM back out of the registry, one file per
platform, and uploads it to the release that `auto-release.yml` created right
after pushing the tag (the upload runs after a multi-minute build, so the
release exists by then; a tag with no release warns and skips, any other
lookup error fails the step). The job carries its own `permissions` block (`contents: write`
for the release upload, `packages: write`, `id-token: write`, `attestations:
write`); the top-level token has none of the signing scopes. The `docker-test`
and `security-scan` builds are never pushed and stay unchanged.
What it proves: that this digest was produced by this workflow, at this commit,
in this repository. What it does not prove: that the commit was reviewed, that
the release was authorised, or that a pinned third-party action in the build did
what it claims. See [Pinning third-party actions](#pinning-third-party-actions).
Verification steps for users are in
[DOCKER_DEPLOYMENT.md](DOCKER_DEPLOYMENT.md#verifying-the-image).
**Platforms Built**:
- `main` and `v*` tags → **`linux/amd64` + `linux/arm64`** (multi-arch release builds)
- `development` and other pushes → **`linux/amd64` only**
> arm64 is built via QEMU emulation and is slow, so it is reserved for release
> builds on `main`/tags. `development` pushes build amd64 only to keep the
> pipeline fast. The published `main`/tag images remain multi-arch.
**Tags Generated**:
| Branch/Tag | Docker Hub Tags | GHCR Tags |
|------------|----------------|-----------|
| `main` with tag | `latest`, `1.0.0`, `main` | `latest`, `1.0.0`, `main` |
| `main` without tag | `1.0.0-main-abc1234`, `main` | `1.0.0-main-abc1234`, `main` |
| `develop` | `1.0.0-dev-abc1234`, `develop` | `1.0.0-dev-abc1234`, `develop` |
**Published Registries**:
- **Docker Hub**: `<username>/actual-sync:<tag>`
- **GitHub Container Registry**: `ghcr.io/<owner>/actual-sync:<tag>`
---
### 8. Security Scan
**Purpose**: Scan Docker images for vulnerabilities
**Runs on**: All triggers (after Docker test build)
**Dependencies**: `version`, `docker-test`
**Steps**:
1. Checkout code
2. Build image for scanning
3. Run Trivy vulnerability scanner
4. Generate SARIF report
5. Upload to GitHub Security tab
6. Display summary table
**Duration**: ~2-3 minutes
**Scanner**: [Trivy](https://github.com/aquasecurity/trivy) by Aqua Security
**Severity Levels Scanned**:
- CRITICAL
- HIGH
- MEDIUM
**Reports Generated**:
- SARIF format for GitHub Security
- Table format for workflow summary
- Detailed JSON for archival
**Where to View Results**:
1. GitHub Security tab → Code scanning
2. Workflow summary page
3. Pull request checks
**Action on Vulnerabilities**:
- Pipeline continues (warnings only)
- Critical/High vulnerabilities logged
- Manual review recommended
- Automatic fixes suggested when available
### Dependency checks
Two checks look at dependency advisories. Both block a merge when they find a high or critical advisory:
| Check | Where | Blocks? | What it covers |
|-------|-------|---------|----------------|
| `npm audit --audit-level=high` | `test` job in `ci-cd.yml` | Yes, on high or critical | The whole installed tree, on every push and PR. Moderate and low advisories are reported but do not fail the build. |
| `actions/dependency-review-action` (`fail-on-severity: high`) | `dependency-review.yml`, pull requests only | Yes, on high or critical | Only what the PR adds or changes, compared with its base. Catches a vulnerable package introduced by the PR itself. |
Trivy (job 8) is a separate image scan and is still advisory (`exit-code: 0`).
The `npm audit` step runs `node scripts/auditGate.js`, which wraps `npm audit --json` and fails on any high or critical advisory that a live allowlist entry does not cover. The step used to end in `|| echo "continuing..."`, which swallowed every failure; do not restore that.
#### Audit allowlist
When an advisory has no available fix (for example an in-range transitive waiting on an upstream parent bump), accept it explicitly in `.github/audit-allowlist.json`:
```json
{
"entries": [
{ "id": "GHSA-xxxx-xxxx-xxxx", "reason": "no upstream fix; not reachable from our code", "expires": "2026-12-31" }
]
}
```
- `id` is the GHSA id (the last part of the advisory URL that `npm audit` prints), `reason` is required, `expires` is a `YYYY-MM-DD` date.
- An entry suppresses its advisory through the whole `expires` day (UTC). From the next day the advisory fails the gate again, so the decision is remade on a date. Re-decide by fixing the dependency, or by extending the date with a new reason in a reviewed commit.
- A malformed entry (bad id, empty reason, bad date) fails the gate instead of being ignored. The file is empty today because the tree is clean at the `high` threshold.
- Run it locally with `node scripts/auditGate.js`. `src/__tests__/auditGate.test.js` proves that a live entry suppresses and an expired one does not.
Why a small script and not `better-npm-audit`: the gate needs an expiry that fails the build, a rule the project wants to own and test in jest, and nothing new to install, pin and keep patched in a job that guards the supply chain. The script is about 100 lines over the stable `npm audit --json` v2 report. Alternatives rejected: `better-npm-audit` (extra dependency, and its exception format would need a wrapper to enforce our expiry rule and be testable here), a `jq` filter in the workflow (untestable inline, no validation), Snyk (account and token, overlaps Dependabot and Trivy).
To make `dependency-review` a required check, add "Dependency review" to the branch protection rules (a repository setting, not something the workflow can do).
### OpenSSF Scorecard
`.github/workflows/scorecard.yml` runs [OpenSSF Scorecard](https://github.com/ossf/scorecard) every Monday 03:30 UTC and on manual dispatch (`gh workflow run scorecard.yml`), and uploads SARIF to the Security tab (code scanning, category `scorecard`) beside CodeQL and Trivy (#255). It measures; it fixes nothing. Its value is that a regression, such as someone adding a tag-pinned action, shows up without anyone having to remember the rule.
The first Scorecard run on `main` sets the baseline score; Scorecard analyses the default branch, so no earlier run is meaningful.
- **Permissions**: top level `contents: read`; the single job adds `security-events: write` (SARIF upload) and `actions: read`. No `id-token`.
- **`publish_results: false`, deliberately.** `true` publishes the score to the OpenSSF public dataset and enables a public badge. That is the maintainer's decision, not a default. To opt in, set it to `true` and add `id-token: write` to the job.
- **Baseline**: not recorded by this PR, because it needs a first run on GitHub. After merge, run the workflow once and note the score and failing checks on #255, then again once the rest of the hardening batch lands to confirm the number moved.
**Checks the project cares about** (act on a drop): `Pinned-Dependencies`, `Token-Permissions`, `Dangerous-Workflow`, `Security-Policy`, `Vulnerabilities`, `Dependency-Update-Tool`, `SAST`, `Signed-Releases`, `Binary-Artifacts`, `Branch-Protection` (within what a single maintainer can enable), `Packaging`.
**Checks deliberately out of scope** (a low score is accepted, do not chase it): `Contributors` (wants contributors from several organisations), `Code-Review` (a single maintainer cannot have a second approver on every PR), `CII-Best-Practices` (a badge programme this project has not applied to), `Fuzzing` (no fuzz targets worth maintaining for a sync service), `Maintained` and `CI-Tests` heuristics where they simply reflect repository age or PR volume.
### Pinning third-party actions
A `uses:` line that points at a tag or branch runs whatever that ref holds at the moment the job starts, with the job's token and secrets. Tags are mutable: in March 2025 `tj-actions/changed-files` had its tags repointed to a credential-dumping commit, and every workflow tracking it ran the payload. So (#249):
- **Every `uses:` in `.github/` is a full 40-character commit SHA with the version in a trailing comment**, for example `actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1`. There are no exceptions; local actions (`./...`) are the only thing exempt.
- **The guard**: `scripts/check-action-pins.sh` fails on any unpinned reference (printing `file:line`) and on a SHA without a `# vX.Y.Z` comment. The `Action pins` workflow (`action-pins.yml`) runs it, plus its contract test `scripts/test-action-pins.sh`, on every push and pull request. `src/__tests__/workflowPins.test.js` repeats the rule inside `npm test`.
- **Dependabot keeps the pins current.** The `github-actions` entry in `.github/dependabot.yml` understands SHA pins with version comments and rewrites both. Expect one PR per action release (after the cooldown), which is the accepted cost of pinning: a security patch to an action now arrives through a merged Dependabot PR rather than on the next run.
- `actions/checkout` is on one version (v7.0.1) everywhere; `leak-guard.yml` used to be on v4.
**Adding or updating an action by hand**:
```bash
# 1. resolve the tag to a commit SHA
gh api repos/OWNER/REPO/git/ref/tags/vX.Y.Z
# 2. if .object.type is "tag" (annotated), follow it to the commit
gh api repos/OWNER/REPO/git/tags/<sha-from-step-1>
# 3. write: uses: OWNER/REPO@<40-hex commit sha> # vX.Y.Z
bash scripts/check-action-pins.sh
```
Pin to the commit, not to the annotated tag object's own SHA (step 2 matters).
### Release (separate workflow)
Releases are **not** a job in this pipeline. They are handled by the dedicated
**Auto Release** workflow (`.github/workflows/auto-release.yml`), which runs
*after* this CI/CD Pipeline finishes successfully on `main`.
Why separate: keying a release off an unbumped `package.json` version meant the
duplicate-tag guard skipped every release, so dependency updates never produced a
new release (issue #86). The Auto Release workflow instead **patch-bumps** the
version on each successful `main` run and creates the release.
**Trigger**: `workflow_run` — when "CI/CD Pipeline" completes on `main`.
**Guard**: only runs if `workflow_run.conclusion == 'success'`; skips its own bump
commit (message prefix `chore(release): bump version`) to avoid a release loop.
**Steps**:
1. Generate a GitHub App token (`APP_ID` / `APP_PRIVATE_KEY` secrets).
2. Check out the exact validated commit (`workflow_run.head_sha`).
3. `npm run version:bump -- patch` (updates `VERSION`, `package.json`, `package-lock.json`).
4. Commit, tag `vX.Y.Z`, push commit + tag to `main`.
5. Create the GitHub Release with auto-generated notes.
> The App token is required (not `GITHUB_TOKEN`) so the new `v*` tag triggers this
> pipeline's tag-based `docker-publish`, producing a version-tagged image. That
> tag run is also what attaches the SBOM files to the release (#252), so a
> release has no SBOM asset until its Docker publish finishes.
See [VERSIONING.md](VERSIONING.md#automated-releases) for the full flow.
---
### Automated @actual-app/api updates — the release train
`.github/workflows/dependency-update.yml` ("Actual API Release Train") runs daily
at 01:00 UTC (and on demand). It is the **only** fully automated publish path in
the repo, and it covers **only** `@actual-app/api`. Every other dependency —
including all other production deps — stays on the normal Dependabot →
`development` → manual-promotion route.
When a newer `@actual-app/api` is published, the run:
1. **Fast-forwards `development` up to `main`** (`git merge --ff-only`). If that is
not possible it opens a **sync PR** instead (see below).
2. **Branches off `main`** and bumps the dependency only — *not* the version.
3. **Opens a PR to `main`** and waits for the full CI pipeline to pass.
4. **Merges it.** `ci-cd.yml` then runs on `main`, `auto-release.yml` patch-bumps
+ tags + publishes, and the `v*` tag builds and pushes the Docker images.
5. **Verifies the chain actually started** — polls for the `ci-cd.yml` run on the
merge commit (hard fail if it never appears), then watches it best-effort.
No human interaction at any point. On CI failure nothing is merged: the PR is
left open for inspection and the run is marked failed.
**Why step 5 exists (silent-stall detector).** Without it the workflow ends at the
merge and merely *assumes* the rest of the chain runs. If the GitHub App token were
ever misconfigured, the merge would land, `ci-cd.yml` would never trigger,
`auto-release.yml` would never fire, nothing would publish — and the job would still
exit **green**. A correct App token prevents that stall; this step is what turns a
*regression* of it into a red run instead of silence. (Adopted from
`actual-mcp-server`, which shipped a release with no Docker image exactly this way.)
It runs on `github.token` rather than the App token: the calls are read-only, and
`GITHUB_TOKEN` cannot expire mid-watch the way a 60-minute App token can.
**Two tiers, deliberately.** *Trigger proof* is a **hard gate** — that is the
App-token failure mode, and 10 minutes of polling is cheap. The *completion watch*
is **observational and capped at 20 minutes**, because `auto-release.yml` self-gates
on `workflow_run.conclusion == 'success'` and so nothing here decides whether the
release happens. Still running at the cap is reported and passes; there is no value
in paying a hosted runner to idle for a verdict that changes nothing.
The watch reads the run's **conclusion explicitly** rather than inferring it from
`gh run watch --exit-status`, which also exits non-zero on a **cancelled** run. A
cancelled run (e.g. superseded by a newer push, if `ci-cd.yml` ever gains
`concurrency`/`cancel-in-progress` on `main`) is reported as its own outcome, not
misdiagnosed as a failure.
**"Superseded" is verified, not assumed.** Accepting *cancelled* as benign is only
valid if something newer actually ran — a run cancelled by hand, or by a push whose
own run then went red, ends with nothing published. So on `cancelled` the workflow
checks for a **newer `ci-cd.yml` run on `main`** and only then passes; with no newer
run it fails loudly. Otherwise the silent stall this whole step exists to prevent
simply reappears one layer in.
**Race vs. divergence — they are different failures.** A failed `--ff-only` means
`development` genuinely diverged: the workflow opens a `main` → `development` sync PR
rather than only emitting a warning, which is easy to miss. A **rejected push** means
someone pushed to `development` between the fetch and the push — a transient race,
*not* a divergence. That path re-fetches and retries once, then warns without opening
a PR, so a race never produces a misleading "diverged" annotation or a spurious PR.
The divergence path first checks for an already-open sync PR so it cannot spam a
duplicate daily. The PR is created with the **App token**, not `github.token`, so it
actually receives CI runs — a PR opened by `GITHUB_TOKEN` does not trigger workflows.
Note that **closing the sync PR does not suppress it**: if `development` is still
diverged the next day, a new one is opened.
**HEAD restoration is a `trap`, not a trailing command.** The sync step restores
`HEAD` to `main` via `trap ... EXIT`. Cleanup written as the last line of a `set -e`
block is unreachable on every failure path, and git working state persists *across
steps* in a job. The release branch is cut with an explicit `origin/main` start
point so it does not depend on this, but the invariant is stated where it belongs
instead of being absorbed by a later step.
The whole sync step carries `continue-on-error: true`: it is best-effort by design
and must never block the dependency from shipping, since the release branches off
`main` and does not depend on `development` at all. Authenticating the git remote is
deliberately kept in a **separate, non-best-effort step**, because every later push
depends on it and a failure there must fail the job rather than be swallowed.
**Majors are in scope.** The only version move the train refuses is a backwards
one — the comparison is a forward-only `sort -V` check, which also guards against
the upstream `latest` dist-tag being rolled back to an older release (`npm show`
returns the tag, not the highest published version).
**Ownership: Dependabot ignores `@actual-app/api` entirely.**
| Update type | Owned by | Mechanism |
|---|---|---|
| `@actual-app/api` (**any** forward move, incl. majors) | `dependency-update.yml` | auto-publishes to `main` after full CI passes |
| Direct dependency — **version** update | Dependabot entry 1 | PR to `development`, promoted manually |
| Direct dependency — **security** update | Dependabot entry 2 | opens on **`main`**, retargeted hourly to `development` |
| **Transitive** dependency (either kind) | nobody — **no PR is opened** | see below |
| GitHub Actions versions | Dependabot, `github-actions` entry | PR to `development`, promoted manually |
| Docker base image (`node:22-alpine` digest) | Dependabot, `docker` entry | PR to `development`, promoted manually |
**Cooldown (#251).** Dependabot version updates are deliberately delayed: npm patch and minor wait 5 days after a release is published, npm major 7 days, GitHub Actions 5 days. A Dependabot PR that "has not appeared yet" for a fresh release is this, not a bug. To take a fix sooner, bump it by hand. Security updates are not delayed (the security entry has no cooldown). The `docker` entry (#362) tracks the Dockerfile base image: both stages pin `node:22-alpine` by digest, Dependabot proposes digest bumps weekly after the same 5 day cooldown, and Node major versions are ignored (the major moves by hand, together with `NODE_VERSION` and `engines.node`).
`dependabot.yml` carries a bare `dependency-name: "@actual-app/api"` ignore rule
with **no** `update-types` filter, in **both** npm entries (entry 1's `ignore` does
not carry over). Narrowing it back to patch/minor would make every major produce
*both* a Dependabot PR and an auto-published release.
**Why security updates land on `main` and not `development`.** `target-branch` is
honoured for *version* updates only; security updates always open against the
**default branch**. That is a platform limitation
([dependabot-core#2767](https://github.com/dependabot/dependabot-core/issues/2767)),
not something fixable in YAML. It is also why entry 2 deliberately **omits**
`target-branch` — an entry with a non-default one is not consulted for security
updates at all — and sets `open-pull-requests-limit: 0` so it contributes no
version updates. See the Dependency Policy in `CLAUDE.md` for the full rationale;
it is not restated here.
**`.github/workflows/retarget-dependabot.yml`** moves those PRs to `development`.
It runs on an **hourly schedule** (`cron: '17 * * * *'`), *not* `pull_request_target`:
a Dependabot-initiated run gets a read-only token and no access to Actions secrets,
so the App-token step would fail on exactly the PRs the workflow exists for. A
scheduled run is not Dependabot-initiated and gets normal secrets. The trade is up
to an hour of latency, which costs nothing because nothing merges those PRs in that
window. The job skips `deps/actual-api-*` so it can never retarget the release
train's own PR away from `main`.
**A retargeted PR is re-tested against its new base (#205).** Changing a base fires
`pull_request` `edited`, which `ci-cd.yml` does not listen for — so the job closes and
reopens the PR, firing `reopened` and re-running the pipeline against `development`.
That works without touching `ci-cd.yml`, which declares no `types:` and so receives
GitHub's default set (`opened`, `synchronize`, `reopened`). Keep it that way: the
earlier attempt added `edited` to `ci-cd.yml`, and on a title edit every real job
skipped while the `if: always()` summary passed — turning a **red PR green** with no
code change (reverted in `5aaf131`).
The red commit status is posted *before* the close and cleared only on **evidence that a
new run started** — a run id greater than a snapshot taken before the close, since the
PR's original `main`-based runs carry the same head SHA and mere existence would prove
nothing. Every failure path in between leaves the PR visibly not-green instead of showing
`main`'s stale check. A PR left closed fails the job loudly, because Dependabot may
decline to recreate a closed security PR.
**A conflicting PR gets no CI run at all (#210)** — GitHub cannot compute
`refs/pull/N/merge`, so `ci-cd.yml` never fires, and close/reopen does not change that.
This is the *normal* state of a retargeted PR: it branches from `main` while
`development` is ahead on `VERSION`/`package.json`. The job leaves the status red and
emits a warning saying a conflict is the likely cause — hedged deliberately, because no
run can also mean `paths-ignore`, a disabled workflow, or a stuck queue.
**Transitive dependencies produce no PR.** Both entries carry
`allow: dependency-type: "direct"`, so a vulnerable transitive never yields a
lockfile-only PR. Clearing one is a `npm update`: npm resolves to the highest
version the parent's **existing** range admits, and a patched release is usually
already inside it — the vulnerable copy is held by the lockfile, not by the range.
Only a fix the parent's range *excludes* genuinely waits for the parent. `overrides`
and `resolutions` remain forbidden either way. See the Dependency Policy in
`CLAUDE.md`.
**Why the version is not bumped in the train:** `auto-release.yml` patch-bumps
precisely when the version on `main` equals the latest tag. Bumping in the PR
would push `main` ahead of the tag, and auto-release would release it *as-is*,
skipping its own bump. Leaving the version untouched is what makes the routine
auto-patch fire.
**Why `development` is synced first, not back-merged after:** `auto-release.yml`
pushes a bump commit to `main` and `metrics-badges.yml` pushes a badge commit
after every `v*` tag, so `main` permanently drifts ahead of `development` once a
release completes. Syncing at the *start* of the next daily run is race-free —
the previous release has long settled. A back-merge at the *end* would race those
two pushes and miss exactly the commits that cause the drift.
**Why `--ff-only` and never `--force`:** when `development` has no unique commits
this is a plain fast-forward, so no force is needed. When `development` *has*
diverged, `--ff-only` fails and changes nothing, whereas `reset --hard` +
force-push would silently destroy in-flight work. The failure is non-fatal — the
release branches off `main` regardless — but `development` then needs the sync PR
merged before it can be promoted, or `auto-release.yml` will refuse the version
regression.
**Timeouts.** The job allows 120 minutes because it waits on CI **twice**: the PR
merge gate (≤45 min) and the post-merge chain verification (≤35 min: 10 min
discovery + 20 min capped watch + slack). The verification step is sized well inside
the job budget on purpose — a *step* timeout does not protect against the *job*
timeout truncating the run, which would kill the step before it can write its
diagnostic summary. Only ever reached when an upgrade actually exists; no-op days
exit in seconds.
**Token handling:** checkout runs with `persist-credentials: false`. The dep bump
uses `npm install --package-lock-only`, which rewrites `package.json` +
`package-lock.json` without populating `node_modules`, so no lifecycle script from
the new package version executes in this job — the freshly bumped code is only
ever run by CI, in its own isolated run. The write-scoped GitHub App token is
required (not `GITHUB_TOKEN`) because pushes made with `GITHUB_TOKEN` do not
trigger workflow runs, so the merge commit would never fire `ci-cd.yml` or
`auto-release.yml`. It is re-minted after the CI wait so a long pipeline cannot
outlive the App token's 60-minute lifetime.
---
### 10. Deployment Test
**Purpose**: Verify published Docker images work correctly
**Runs on**: After Docker publish
**Dependencies**: `version`, `docker-publish`
**Steps**:
1. Pull image from Docker Hub
2. Test Docker Hub image
3. Pull image from GHCR
4. Test GHCR image
5. Verify version environment variable
**Duration**: ~1-2 minutes
**What It Tests**:
- Images are publicly accessible
- Images can be pulled without errors
- Containers start successfully
- Node.js version is correct
- VERSION environment variable is set
**Why This Matters**:
- Confirms images are usable by end users
- Validates registry permissions
- Ensures multi-platform builds work
- Catches publishing errors immediately
---
## Setup Instructions
### Prerequisites
1. **GitHub Repository**: Your code must be in a GitHub repository
2. **Docker Hub Account**: Create at https://hub.docker.com
3. **Node.js Project**: With `package.json` and tests
### Required Repository Secrets
Configure these secrets in GitHub repository settings:
1. Navigate to: `Settings` → `Secrets and variables` → `Actions` → `New repository secret`
2. Add the following secrets:
| Secret Name | Description | Where to Get It |
|-------------|-------------|-----------------|
| `DOCKER_USERNAME` | Docker Hub username | Your Docker Hub account |
| `DOCKER_TOKEN` | Docker Hub access token | Docker Hub → Account Settings → Security → New Access Token |
**Note**: `GITHUB_TOKEN` is automatically provided by GitHub Actions.
### Docker Hub Access Token Creation
1. Login to [Docker Hub](https://hub.docker.com)
2. Click your profile → **Account Settings**
3. Navigate to **Security** tab
4. Click **New Access Token**
5. Name it: `GitHub Actions CI/CD`
6. Permissions: **Read, Write, Delete**
7. Click **Generate**
8. Copy the token (you won't see it again!)
9. Add it to GitHub secrets as `DOCKER_TOKEN`
### GitHub Container Registry Setup
GHCR is automatically configured and uses `GITHUB_TOKEN`. No additional setup required.
**Package Visibility**:
- Go to: `https://github.com/users/<username>/packages/container/actual-sync/settings`
- Set visibility to **Public** (optional, for public access)
### First-Time Setup Steps
1. **Clone repository**:
```bash
git clone https://github.com/<username>/actual-sync.git
cd actual-sync
```
2. **Verify scripts/get_version.sh works**:
```bash
chmod +x scripts/get_version.sh
./scripts/get_version.sh --verbose
```
3. **Add Docker Hub secrets to GitHub**:
- Settings → Secrets → Actions → New repository secret
- Add `DOCKER_USERNAME`
- Add `DOCKER_TOKEN`
4. **Test workflow locally** (optional):
```bash
# Install act (GitHub Actions local runner)
brew install act # macOS
# or
curl https://raw.githubusercontent.com/nektos/act/master/install.sh | sudo bash # Linux
# Run workflow locally
act push --secret DOCKER_USERNAME=<your-username> --secret DOCKER_TOKEN=<your-token>
```
5. **Push to trigger pipeline**:
```bash
git add .
git commit -m "Add CI/CD pipeline"
git push origin develop
```
6. **Monitor workflow**:
- Go to: `Actions` tab in GitHub
- Click on the running workflow
- Watch jobs execute in real-time
### Updating Docker Hub Description
The pipeline automatically updates Docker Hub descriptions on `main` branch:
- **Short description**: `docker/description/short.md` (≤ 100 characters)
- **Long description**: `docker/description/long.md` (markdown supported)
**To update descriptions**:
1. Edit files in `docker/description/`
2. Run validation: `./docker/validate-docker-desc.sh`
3. Commit and push to `main`
4. Pipeline will update Docker Hub automatically
---
## Manual Triggers
The workflow can be manually triggered with custom options.
### How to Manually Trigger
1. Go to: `Actions` tab in GitHub
2. Select: **CI/CD Pipeline** workflow
3. Click: **Run workflow** dropdown
4. Select branch
5. Configure options
6. Click: **Run workflow** button
### Manual Trigger Options
#### 1. Skip Tests (`skip_tests`)
**Default**: `false`
**Options**: `true` or `false`
**Use Case**: Emergency deployments when tests are known to pass
**Example**:
```yaml
skip_tests: true
```
**Warning**: ⚠️ Use cautiously - skipping tests can introduce bugs
---
#### 2. Skip Docker Publish (`skip_docker_publish`)
**Default**: `false`
**Options**: `true` or `false`
**Use Case**: Testing CI pipeline without publishing images
**Example**:
```yaml
skip_docker_publish: true
```
**What Still Runs**:
- Version generation
- Linting
- Testing
- Building
- Docker test build
- Security scanning
**What's Skipped**:
- Docker Hub publishing
- GHCR publishing
- Deployment tests
- Release creation
---
#### 3. Docker Tag Suffix (`docker_tag_suffix`)
**Default**: (empty)
**Format**: String (alphanumeric and hyphens)
**Use Case**: Create custom-tagged images for specific testing
**Example**:
```yaml
docker_tag_suffix: "hotfix-123"
```
**Result**:
```
<username>/actual-sync:1.0.0-dev-abc1234-hotfix-123
ghcr.io/<owner>/actual-sync:1.0.0-dev-abc1234-hotfix-123
```
**Use Cases**:
- Hotfix deployments
- A/B testing
- Customer-specific builds
- Feature testing
---
### Manual Trigger Examples
#### Example 1: Quick Test Run
**Scenario**: Test changes without publishing
**Configuration**:
```yaml
Branch: develop
skip_tests: false
skip_docker_publish: true
docker_tag_suffix: (empty)
```
**Result**: Full testing, no publishing
---
#### Example 2: Emergency Hotfix
**Scenario**: Critical bug fix that needs immediate deployment
**Configuration**:
```yaml
Branch: main
skip_tests: true # Tests already passed locally
skip_docker_publish: false
docker_tag_suffix: "hotfix-critical-auth"
```
**Result**: Fast deployment with custom tag
**Warning**: ⚠️ Use only in true emergencies
---
#### Example 3: Feature Branch Testing
**Scenario**: Test feature branch with custom tag
**Configuration**:
```yaml
Branch: feature/new-auth
skip_tests: false
skip_docker_publish: false
docker_tag_suffix: "test-staging"
```
**Result**: Full pipeline with additional custom tag
---
## Docker Publishing
### Registries
The pipeline publishes to two registries simultaneously:
#### 1. Docker Hub
**Registry**: `docker.io`
**Image Names**:
```
<DOCKER_USERNAME>/actual-sync:<tag>
```
**Examples**:
```bash
docker pull myusername/actual-sync:latest
docker pull myusername/actual-sync:1.0.0
docker pull myusername/actual-sync:develop
```
**Visibility**: Public (configurable in Docker Hub)
**Limits**:
- Free tier: 200 pulls per 6 hours (anonymous)
- Authenticated: Higher limits
---
#### 2. GitHub Container Registry (GHCR)
**Registry**: `ghcr.io`
**Image Names**:
```
ghcr.io/<GITHUB_OWNER>/actual-sync:<tag>
```
**Examples**:
```bash
docker pull ghcr.io/myusername/actual-sync:latest
docker pull ghcr.io/myorganization/actual-sync:1.0.0
```
**Visibility**: Configurable (public or private)
**Authentication** (for private packages):
```bash
echo $GITHUB_TOKEN | docker login ghcr.io -u USERNAME --password-stdin
```
**Limits**: Much higher than Docker Hub free tier
---
### Tag Strategy
#### Branch-Based Tags
| Branch | Tags Generated | Purpose |
|--------|---------------|---------|
| `main` | `latest`, `main`, `<version>` | Production releases |
| `develop` | `develop`, `<version>-dev-<hash>` | Development builds |
| `feature/*` | `<version>-feature-<name>-<hash>` | Feature testing |
#### Version Tags
Format: `<base>-<context>-<commit>`
**Examples**:
```
1.0.0 # Tagged release on main
1.0.0-main-abc1234 # Untagged commit on main
1.0.0-dev-def5678 # Develop branch
1.0.0-feature-auth-ghi901 # Feature branch
```
---
### Multi-Platform Builds
The pipeline builds images for multiple architectures:
**Platforms**:
- `linux/amd64` - Intel/AMD 64-bit (x86_64)
- `linux/arm64` - ARM 64-bit (Apple Silicon, Raspberry Pi 4+)
**Docker automatically selects the correct platform**:
```bash
# On x86_64 machine
docker pull myusername/actual-sync:latest # Pulls amd64
# On ARM64 machine (Apple M1/M2)
docker pull myusername/actual-sync:latest # Pulls arm64
```
**Build Time**:
- Single platform: ~5 minutes
- Multi-platform: ~10-15 minutes
---
### Using Published Images
#### Pull Latest Version
```bash
# Docker Hub
docker pull myusername/actual-sync:latest
# GHCR
docker pull ghcr.io/myusername/actual-sync:latest
```
#### Pull Specific Version
```bash
docker pull myusername/actual-sync:1.0.0
```
#### Run Container
```bash
docker run -d \
--name actual-sync \
-p 3000:3000 \
-v $(pwd)/config:/app/config \
-v $(pwd)/data:/app/data \
myusername/actual-sync:latest
```
#### Docker Compose
```yaml
version: '3.8'
services:
actual-sync:
image: myusername/actual-sync:latest
# or: ghcr.io/myusername/actual-sync:latest
container_name: actual-sync
ports:
- "3000:3000"
volumes:
- ./config:/app/config
- ./data:/app/data
restart: unless-stopped
```
---
## Security Scanning
### Overview
Every build is automatically scanned for vulnerabilities using [Trivy](https://github.com/aquasecurity/trivy) by Aqua Security.
### What Trivy Scans
1. **OS Packages**: Alpine Linux packages
2. **Application Dependencies**: Node.js packages (package.json)
3. **Filesystem**: Configuration files, scripts
4. **Secrets**: Hardcoded credentials (basic detection)
### Severity Levels
| Severity | Description | Action |
|----------|-------------|--------|
| CRITICAL | Actively exploited vulnerabilities | Fix immediately |
| HIGH | Serious vulnerabilities with known exploits | Fix in next release |
| MEDIUM | Moderate risk vulnerabilities | Fix when convenient |
| LOW | Minor issues with low impact | Optional fix |
| UNKNOWN | Unclassified | Review manually |
### SARIF Upload
Results are uploaded to GitHub Security in [SARIF format](https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html) (Static Analysis Results Interchange Format).
**Where to View**:
1. Repository → **Security** tab
2. Click **Code scanning**
3. Filter by **Trivy**
**Benefits**:
- Centralized vulnerability tracking
- Integration with GitHub Advanced Security
- Automated alerts for new vulnerabilities
- Historical tracking
### Scan Frequency
- **Every push**: Main and develop branches
- **Every PR**: Pull request builds
- **Manual runs**: Workflow dispatch
- **Scheduled**: Can add cron schedule (optional)
### Handling Vulnerabilities
#### Critical/High Vulnerabilities
1. **Immediate Review**: Check Security tab
2. **Assess Impact**: Determine if it affects your use case
3. **Update Dependencies**: Run `npm update` or `npm audit fix`
4. **Rebuild**: Push updated `package.json` and `package-lock.json`
5. **Verify**: New scan should show vulnerability resolved
#### Medium/Low Vulnerabilities
1. **Track**: Add to backlog
2. **Monitor**: Watch for updates
3. **Plan**: Include in next maintenance release
### Example Scan Results
```
┌───────────────┬─────────────────┬──────────┬────────────────┬───────────────┬───────────────┐
│ Library │ Vulnerability │ Severity │ Installed Ver. │ Fixed Ver. │ Title │
├───────────────┼─────────────────┼──────────┼────────────────┼───────────────┼───────────────┤
│ express │ CVE-2024-12345 │ HIGH │ 4.17.1 │ 4.18.2 │ XSS in query │
│ sqlite3 │ CVE-2024-67890 │ MEDIUM │ 5.0.0 │ 5.1.0 │ DoS issue │
└───────────────┴─────────────────┴──────────┴────────────────┴───────────────┴───────────────┘
```
### Suppressing False Positives
Create `.trivyignore` file in repository root:
```
# False positive: Not used in our code
CVE-2024-12345
# Accepted risk: No fix available yet
CVE-2024-67890
```
### Custom Scan Configuration
Modify workflow to customize scanning:
```yaml
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ env.DOCKER_IMAGE_NAME }}:scan
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'CRITICAL,HIGH,MEDIUM,LOW' # Add LOW
ignore-unfixed: true # Ignore vulnerabilities with no fix
```
---
## Troubleshooting
### Common Issues
#### Issue 1: Workflow Not Triggering
**Symptoms**: Push to branch doesn't start workflow
**Possible Causes**:
1. Workflow file has syntax errors
2. Branch name doesn't match triggers
3. `.github/workflows/` directory in wrong location
**Solutions**:
```bash
# Validate workflow syntax
cat .github/workflows/ci-cd.yml | grep -v "^#" | grep -v "^$"
# Check branch name
git branch --show-current
# Verify file location
ls -la .github/workflows/
# Push to correct branch
git push origin main
```
---
#### Issue 2: Docker Login Fails
**Symptoms**: Error: `Error: Cannot perform an interactive login from a non TTY device`
**Cause**: Missing or incorrect Docker Hub credentials
**Solutions**:
1. Verify secrets exist:
- Go to: Settings → Secrets → Actions
- Check: `DOCKER_USERNAME` and `DOCKER_TOKEN` are present
2. Regenerate Docker Hub token:
- Docker Hub → Account Settings → Security
- Delete old token
- Create new token
- Update `DOCKER_TOKEN` secret in GitHub
3. Check token permissions:
- Token must have: Read, Write, Delete
- Read-only tokens will fail on push
---
#### Issue 3: Version Script Fails
**Symptoms**: Error: `scripts/get_version.sh: command not found` or permission denied
**Cause**: Script not executable or missing
**Solutions**:
```bash
# Make script executable
chmod +x scripts/get_version.sh
# Commit permission change
git add scripts/get_version.sh
git commit -m "Make version script executable"
git push
# Verify locally
./scripts/get_version.sh --verbose
```
---
#### Issue 4: Docker Build Fails
**Symptoms**: Error during Docker build step
**Common Errors**:
**Error: `failed to solve: process "/bin/sh -c npm ci" did not complete successfully`**
**Solution**:
```dockerfile
# In Dockerfile, ensure package.json is copied before npm ci
COPY package*.json ./
RUN npm ci
```
**Error: `VERSION: unset variable`**
**Solution**:
```dockerfile
# Add default value for VERSION
ARG VERSION=unknown
ENV VERSION=${VERSION}
```
**Error: `Health check failed`**
**Solution**:
```dockerfile
# Increase start-period in HEALTHCHECK
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD ...
```
---
#### Issue 5: Tests Fail in CI But Pass Locally
**Symptoms**: Tests pass on local machine but fail in GitHub Actions
**Possible Causes**:
1. Different Node.js versions
2. Missing environment variables
3. Timezone differences
4. Filesystem path differences
**Solutions**:
```bash
# Test with same Node version as CI
nvm use 20
npm test
# Run tests in Docker (same as CI)
docker run --rm -v $(pwd):/app -w /app node:22-alpine npm test
# Check for hardcoded paths
grep -r "$(pwd)" src/
# Add debug logging
npm test -- --verbose
```
---
#### Issue 6: Security Scan Fails
**Symptoms**: Trivy scan step fails or reports many vulnerabilities
**Solutions**:
**For CRITICAL/HIGH vulnerabilities**:
```bash
# Update all dependencies
npm update
# Run audit and fix automatically
npm audit fix
# For breaking changes, upgrade manually
npm install <package>@latest
# Commit updated dependencies
git add package*.json
git commit -m "Update dependencies to fix vulnerabilities"
git push
```
**For false positives**:
```bash
# Create .trivyignore file
cat > .trivyignore << EOF
# False positive for our use case
CVE-2024-12345
EOF
git add .trivyignore
git commit -m "Suppress false positive CVE-2024-12345"
git push
```
---
#### Issue 7: Multi-Platform Build Timeout
**Symptoms**: Docker build takes too long or times out
**Cause**: Multi-platform builds (amd64 + arm64) are slow
**Solutions**:
**Option 1: Reduce platforms**:
```yaml
env:
DOCKER_PLATFORMS: linux/amd64 # Only build for amd64
```
**Option 2: Use GitHub larger runners**:
```yaml
docker-publish:
runs-on: ubuntu-latest-4-cores # Requires GitHub Teams/Enterprise
```
**Option 3: Enable build caching**:
```yaml
- name: Build and push
uses: docker/build-push-action@v5
with:
cache-from: type=gha
cache-to: type=gha,mode=max # Already enabled
```
---
#### Issue 8: GHCR Push Fails
**Symptoms**: Docker Hub push works but GHCR fails
**Cause**: Package visibility or permissions issue
**Solutions**:
1. Check package exists:
- Go to: `https://github.com/<username>/actual-sync/pkgs/container/actual-sync`
2. Set visibility to public:
- Package settings → Change visibility → Public
3. Grant workflow permissions:
- Repository Settings → Actions → General
- Workflow permissions → Read and write permissions
4. Verify token scopes:
- `GITHUB_TOKEN` should have: `write:packages`
---
#### Issue 9: Release Not Created
**Symptoms**: Docker images published but no GitHub release
**Cause**: Release job only runs on tagged commits on main
**Requirements**:
1. Must be on `main` branch
2. Must have Git tag starting with `v`
3. Docker publish must succeed
4. Security scan must succeed
**Create release manually**:
```bash
# On main branch
git tag v1.0.0
git push origin v1.0.0
# Watch Actions tab for release job
```
---
#### Issue 10: Short Description Validation Fails
**Symptoms**: `validate-short-description` job fails
**Cause**: Docker Hub short description exceeds 100 characters
**Check character count**:
```bash
wc -c < docker/description/short.md
# Must output <= 100
```
**Fix description**:
```bash
# Edit file to be shorter
nano docker/description/short.md
# Validate
./docker/validate-docker-desc.sh
# Commit
git add docker/description/short.md
git commit -m "Shorten Docker Hub description"
git push
```
---
### Debugging Workflows
#### View Detailed Logs
1. Go to: Actions tab
2. Click on failed workflow
3. Click on failed job
4. Expand failed step
5. Review error messages
#### Re-run Failed Jobs
1. Go to: Actions → Failed workflow
2. Click: **Re-run jobs** dropdown
3. Select: **Re-run failed jobs** or **Re-run all jobs**
#### Enable Debug Logging
Add secrets to repository:
- `ACTIONS_STEP_DEBUG` = `true`
- `ACTIONS_RUNNER_DEBUG` = `true`
**Result**: Much more verbose logging in workflow runs
#### Download Artifacts
Some jobs upload artifacts (test results, coverage, etc.):
1. Go to: Actions → Workflow run
2. Scroll to: **Artifacts** section
3. Click: Download artifact
---
### Getting Help
#### Resources
1. **GitHub Actions Documentation**: https://docs.github.com/en/actions
2. **Trivy Documentation**: https://aquasecurity.github.io/trivy/
3. **Docker Build Documentation**: https://docs.docker.com/build/
4. **Node.js CI Best Practices**: https://nodejs.org/en/docs/guides/
#### Support Channels
1. **Repository Issues**: Create issue with workflow run link
2. **GitHub Community**: https://github.community/
3. **Stack Overflow**: Tag questions with `github-actions`, `docker`, `nodejs`
#### Reporting Workflow Bugs
When reporting issues, include:
1. Workflow run URL
2. Error messages
3. Repository structure (anonymized if needed)
4. Secrets configuration (names only, not values!)
5. Expected vs actual behavior
---
## Maintenance
### Regular Tasks
#### Weekly
- Review security scan results
- Update dependencies if vulnerabilities found
- Monitor Docker image sizes
#### Monthly
- Review workflow performance
- Update GitHub Actions versions
- Clean up old Docker images in registries
#### Quarterly
- Audit workflow efficiency
- Review and update documentation
- Test disaster recovery procedures
### Updating Workflow
To modify the CI/CD pipeline:
1. **Edit workflow file**:
```bash
nano .github/workflows/ci-cd.yml
```
2. **Test changes locally** (with act):
```bash
act push -j lint # Test single job
```
3. **Push to feature branch**:
```bash
git checkout -b feature/update-cicd
git add .github/workflows/ci-cd.yml
git commit -m "Update CI/CD workflow"
git push origin feature/update-cicd
```
4. **Create pull request** and verify workflow runs
5. **Merge to main** after verification
### Monitoring
#### Workflow Success Rate
GitHub provides metrics:
- Go to: Insights → Actions
- View success/failure rates
- Track job duration trends
#### Docker Image Pulls
**Docker Hub**:
- Go to: Repositories → actual-sync → Insights
- View pull statistics
**GHCR**:
- Go to: Package settings → Insights
- View pull statistics
---
## Advanced Topics
### Workflow Optimization
#### Caching Strategy
The workflow uses multiple caching layers:
1. **npm cache** (via `actions/setup-node`):
```yaml
- uses: actions/setup-node@v4
with:
cache: 'npm' # Caches ~/.npm
```
2. **Docker build cache** (via GitHub Actions cache):
```yaml
cache-from: type=gha
cache-to: type=gha,mode=max
```
**Benefits**:
- Faster dependency installation (~30s → ~5s)
- Faster Docker builds (~5min → ~2min)
- Reduced bandwidth usage
#### Parallel Job Execution
Jobs run in parallel when possible:
```
lint, test, validate-description ← All run simultaneously
```
**Total time saved**: ~2-3 minutes per workflow run
### Custom Notifications
Add workflow notifications to Slack, Discord, or email.
#### Slack Example
Add to workflow:
```yaml
- name: Notify Slack on failure
if: failure()
uses: slackapi/slack-github-action@v1
with:
webhook-url: ${{ secrets.SLACK_WEBHOOK_URL }}
payload: |
{
"text": "CI/CD Pipeline failed for ${{ github.repository }}",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": ":x: *Build Failed*\n*Repository:* ${{ github.repository }}\n*Branch:* ${{ github.ref_name }}\n*Version:* ${{ needs.version.outputs.version }}"
}
}
]
}
```
---
## Best Practices
### Version Control
1. **Tag releases properly**:
```bash
git tag -a v1.0.0 -m "Release version 1.0.0"
git push origin v1.0.0
```
2. **Use semantic versioning**: `MAJOR.MINOR.PATCH`
- MAJOR: Breaking changes
- MINOR: New features (backward compatible)
- PATCH: Bug fixes
3. **Keep version in package.json updated**
### Security
1. **Never commit secrets** to repository
2. **Rotate Docker Hub tokens** every 90 days
3. **Review security scans** weekly
4. **Update dependencies** monthly
5. **Use minimal Docker base images** (Alpine Linux)
### Performance
1. **Enable all caching** (npm, Docker layers)
2. **Run jobs in parallel** when possible
3. **Skip unnecessary steps** (use conditions)
4. **Use multi-stage Docker builds**
5. **Monitor workflow duration**
### Documentation
1. **Keep this document updated** with changes
2. **Document custom modifications** inline
3. **Add comments to complex workflow steps**
4. **Update README** with CI/CD badges
---
## CI/CD Badges
Add these badges to README.md:
```markdown



```
**Result**:



---
## Conclusion
This CI/CD pipeline provides:
✅ **Automated testing** on every push
✅ **Dynamic versioning** based on Git context
✅ **Multi-platform Docker builds** (amd64 + arm64)
✅ **Dual registry publishing** (Docker Hub + GHCR)
✅ **Security vulnerability scanning** with SARIF upload
✅ **Automated releases** with changelogs
✅ **Deployment verification** for published images
✅ **Comprehensive monitoring** and summaries
**Total pipeline time**: ~15-20 minutes for full run
**Next Steps**:
1. Complete setup instructions above
2. Push code to trigger first run
3. Monitor Actions tab
4. Review and adjust as needed
**Questions?** Open an issue in the repository!