ccpod Internals Reference
Implementation details for contributors and advanced users.
Dependencies
Section titled “Dependencies”| Package | Purpose |
|---|---|
citty | CLI framework (lightweight, Bun-native) |
zod | Schema validation for config files |
yaml | YAML parse/serialize |
deepmerge | Deep-merge for settings.json |
@inquirer/prompts | Interactive wizard prompts |
simple-git | Git operations for profile sync |
chalk | Terminal output colouring |
Docker operations use the docker CLI via Bun.spawn — no Docker SDK dependency. Captured commands support optional kill-and-reap timeouts. Resolved env for run/create is transported with a private temporary env-file, never in the Docker client’s environment or argv, then cleaned up after the child completes; values containing line breaks (which the line-based file format cannot carry) fall back to a bare -e KEY read from the Docker client’s environment. Directories are PID-tagged and dead-owner leftovers from SIGKILL are swept on the next start.
Core types
Section titled “Core types”Most shared types live in src/types/index.ts. The ContainerSpec type is the exception — it is declared in src/container/builder.ts next to its builder.
export type SyncStrategy = 'always' | 'daily' | 'pin';export type PermissionsPreset = 'conservative' | 'moderate' | 'permissive';
type MergeStrategy = 'deep' | 'override';type StateMode = 'ephemeral' | 'persistent';type NetworkPolicy = 'full' | 'restricted';type AuthType = 'api-key' | 'oauth' | 'proxy';type ClaudeMdMerge = 'append' | 'override';
interface PortsConfig { autoDetectMcp: boolean; list: string[];}
interface PortMapping { host: number; container: number;}
export interface ServiceConfig { image: string; env?: Record<string, string>; volumes?: string[]; ports?: string[];}
export interface ProfileConfig { name: string; description?: string; claudeArgs: string[]; config: { source: 'local' | 'git'; path?: string; repo?: string; sync?: SyncStrategy; ref?: string; }; image: { use: string; dockerfile?: string; }; init: string[]; auth: { type: AuthType; keyEnv?: string; keyFile?: string; }; permissions?: PermissionsPreset; plugins: string[]; state: StateMode; ssh: { agentForward: boolean; mountSshDir: boolean; }; isolation: boolean; network: { policy: NetworkPolicy; allow: string[]; }; ports: PortsConfig; services: Record<string, ServiceConfig>; env: string[]; allowProjectHostMounts: boolean; allowProjectEnvForward: string[]; allowProjectInit: boolean; allowProjectServices: boolean;}
export interface ProjectConfig { profile?: string; merge?: MergeStrategy; claudeArgs?: string[]; init?: string[]; config?: { claudeMd?: ClaudeMdMerge; }; network?: Partial<ProfileConfig['network']>; ports?: Partial<PortsConfig>; services?: Record<string, ServiceConfig>; env?: string[];}
// Fully resolved config after all layers mergedexport interface ResolvedConfig { profileName: string; image: string; dockerfile?: string; auth: ProfileConfig['auth']; autoDetectMcp: boolean; state: StateMode; ssh: ProfileConfig['ssh']; network: ProfileConfig['network']; ports: PortMapping[]; services: Record<string, ServiceConfig>; env: Record<string, string>; init: string[]; plugins: string[]; mergedConfigDir: string; claudeArgs: string[];}ContainerSpec is consumed by src/container/runner.ts and by buildContainerSpec callers in src/cli/commands/.
~/.claude assembly
Section titled “~/.claude assembly”Four mount points feed ~/.claude/ inside the container. docker/entrypoint.sh assembles the final directory at startup:
Host mounts Inside container ~/.claude/ result───────────────── ───────────────── ─────────────────────${TMPDIR}/ccpod-u<uid>/ccpod-<hash>/ ──ro──► /ccpod/config/ ──► settings.json (copied) settings.json CLAUDE.md (copied) CLAUDE.md skills/ (copied) hooks/ hooks/ (copied) skills/ extensions/ (copied) extensions/ … (copied)
~/.ccpod/creds/<p>/ ──rw──► /ccpod/credentials/ ──► *.json auth files (overlays config)
ccpod-plugins-<p> (volume) ► /ccpod/plugins/ ──► plugins/ ← symlink~/.ccpod/state/<p>/ (bind rw)► /ccpod/state/ ──► projects/ ← symlink todos/ ← symlink statsig/ ← symlink$PWD ──rw──► /workspace/docker/entrypoint.sh (full source: docker/entrypoint.sh) runs as root for setup, then drops to the node user with gosu. In order:
- Root
PATHandgosu. Root-side commands run with a fixed, root-ownedPATH;gosuis resolved once under it. The image/profilePATH(USER_PATH) is only given to thenodeuser, so nothingnodecan write is ever executed as root. - Host uid remap (Linux). If
CCPOD_HOST_UID/CCPOD_HOST_GIDare set (ccpod sets them on native Linux),nodeis remapped to the host user’s ids so bind-mounted dirs keep your ownership. - Seed config.
/ccpod/config(read-only) is copied into~/.claude. - Restore auth files (
.credentials.json,.claude.json) from/ccpod/credentials— skipped in proxy mode. - Plugins and state.
~/.claude/pluginsandprojects/todos/statsigbecome symlinks into/ccpod/pluginsand/ccpod/state. - Ownership.
chown -R node:nodeon the Claude dir,$HOME,/ccpod/pluginsand/ccpod/state— never/ccpod/credentials, which only root touches. - Init commands. If
/ccpod/config/post-init.shexists (generated only from trust-gatedinit:commands) it runs asnodein/workspace. - Delta plugin install from
CCPOD_PLUGINS_TO_INSTALL. - Network restriction (
CCPOD_NETWORK_POLICY=restricted, needsNET_ADMIN, applied after steps 6–7). Fails closed: loopback/established/default-deny rules for IPv4 and (when the kernel has IPv6)ip6tables; DNS only to the/etc/resolv.confnameservers; declared hosts resolved to IPs; in proxy mode, only the auth proxy’s single port on the host gateway (parsed fromANTHROPIC_BASE_URL). - Launch. Shell mode (
CCPOD_SHELL_MODE=1)execs directly so bash gets TTY job control. Normal mode runs the command as a background job with signal forwarding, then copies the auth files back to/ccpod/credentialsbefore exiting with the command’s status.
Credential persistence: two auth files survive container removal via the bind-mounted credentials dir:
| File | Location inside container | Contains |
|---|---|---|
.credentials.json | $CLAUDE_CONFIG_DIR/.credentials.json | OAuth access/refresh tokens |
.claude.json | $HOME/.claude.json (ignores CLAUDE_CONFIG_DIR) | Account metadata, migration flags |
Proxy auth mode (auth.type: proxy): when a profile uses proxy auth, the credential bind mount and copy-in/copy-out are skipped entirely. Instead, ccpod run starts a local HTTP proxy (src/auth/proxy.ts) before the container. The container receives ANTHROPIC_BASE_URL (pointing at the proxy via host.docker.internal) and a format-valid sentinel ANTHROPIC_API_KEY (sk-ant-api03-ccpod-proxy-...). Claude runs in API-key mode — no .credentials.json, no refresh token, no OAuth refresh flow inside the container. The proxy strips the sentinel x-api-key header, adds Authorization: Bearer <real-oauth-access-token>, and forwards to api.anthropic.com. The proxy holds the only OAuth session (read from the host Keychain or ~/.claude/.credentials.json), refreshes the access token with a single-flight lock (proactively before expiry and on-demand on 401), and writes refreshed tokens back to the host store. This eliminates the refresh-token rotation race that occurs when multiple containers or a container + native claude share the same OAuth login.
Config merging pipeline
Section titled “Config merging pipeline”load_profile_config(name) → detect source (local | git) → git: sync_if_needed(strategy) → read files → local: read files directly → parse profile.yml (Zod validation; a leading `~` in config.path is expanded to $HOME)
load_project_config($PWD) → find .ccpod.yml walking up from $PWD → parse (Zod validation) → determine merge strategy (default: deep)
merge(profile_assets, project_overrides, strategy): settings.json → deepmerge(profile, project) // project wins on conflict CLAUDE.md → claudeMdMerge(profile, project, mode) // append or override skills/ → union(profile_skills, project_skills) // skip symlinks enabledPlugins → union(...) // or replace if strategy=override hooks/ → mergeArraysByEventType(profile, project) marketplaces → { ...profile_markets, ...project_markets }
write_merged_config(result) → ${TMPDIR}/ccpod-u<uid>/ccpod-<sha256(content)>/ // deterministic path: same content = same dir = skip re-writeGit profile checkout ownership
Section titled “Git profile checkout ownership”syncGitConfig verifies every existing cached config directory before reuse, even when the sync policy is daily or pin: the path cannot be a symlink, must resolve to the Git worktree root, must have the configured origin (a trailing / and scheme/host casing are treated as spelling; a .git suffix, scp-style, and ssh:// forms are not, since they can name different repositories), and must have no tracked modifications. Untracked files are ignored when reusing a checkout, but a reset is refused when an untracked file, directory, or ignored path would collide with the incoming revision. It verifies again after fetch and immediately before reset --hard FETCH_HEAD. Unsafe checkouts fail closed without reset or use. A failed remote fetch may use the previously verified clean checkout; partial initial clones are made in a temporary directory and renamed into place.
Env forwarding (resolveEnvForwarding)
Section titled “Env forwarding (resolveEnvForwarding)”src/auth/resolver.ts:resolveEnvForwarding collapses profile.env, projectConfig.env, and CLI --env overrides into a single Record<string, string>. The runtime transports that map through a private temporary env-file and removes it after the Docker command exits; values carrying line breaks use a bare -e KEY with the value in the Docker client’s own environment instead, because the env-file format is line-based. Each entry has one of three forms:
KEY— forwardprocess.env.KEY(entry skipped if unset on host)KEY=value— literal valueKEY=...${VAR}.../KEY=...${VAR:-default}...— interpolate host vars into the value
Interpolation is governed by INTERPOLATION_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g. Missing host vars without :-default resolve to empty string and warn once per unique name. Interpolation runs on env values only — other config string fields are taken verbatim by design (limits attack surface from project-controlled .ccpod.yml).
Project entries cannot interpolate. Because a repo’s .ccpod.yml is untrusted input, ${VAR} in a project-sourced entry throws an error rather than reading from process.env. Profile- and CLI-sourced entries retain full interpolation. KEY=literal works everywhere. Bare KEY forwarding works in profile and CLI entries; in project entries it is ignored with a warning unless the profile lists the name in allowProjectEnvForward (forwarding a host value by name is the same exfiltration path as ${VAR}).
Project entries cannot set redirect/injection/TLS keys. Project-sourced entries whose key (matched case-insensitively) is on PROJECT_ENV_DENYLIST are ignored with a warning. The list groups into: Anthropic credential/endpoint (ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, ANTHROPIC_BASE_URL / ANTHROPIC_BEDROCK_BASE_URL / ANTHROPIC_VERTEX_BASE_URL), proxy (HTTP_PROXY / HTTPS_PROXY / ALL_PROXY / NO_PROXY), code injection (NODE_OPTIONS / BUN_OPTIONS), binary/library hijack (PATH, HOME, SHELL, BASH_ENV, ENV, IFS, CLAUDE_CONFIG_DIR, and every LD_* / DYLD_* key), and TLS trust (NODE_EXTRA_CA_CERTS, NODE_TLS_REJECT_UNAUTHORIZED, SSL_CERT_FILE, SSL_CERT_DIR, CURL_CA_BUNDLE, REQUESTS_CA_BUNDLE). Otherwise a repo could redirect API traffic to exfiltrate the profile’s resolved credential, inject code into the credential-bearing Node process, or disable TLS verification. Profile and CLI entries are trusted and may set them.
Project entries cannot set CCPOD_* or DOCKER_* keys. Project-sourced entries whose key starts (case-insensitively) with CCPOD_ or DOCKER_ are ignored with a warning. CCPOD_* could override ccpod’s own control vars (e.g. CCPOD_NETWORK_POLICY=full to bypass the profile’s restricted network), and DOCKER_* (e.g. DOCKER_HOST=tcp://attacker:2375) could redirect the docker CLI itself to an attacker-controlled daemon. builder.ts additionally strips CCPOD_* and DOCKER_* from secretEnv as defense-in-depth, so even a profile-sourced value cannot reach the container as a -e flag that would override ccpod’s constructed control vars.
Source precedence: profile → project → CLI override (later wins).
Project config trust boundary
Section titled “Project config trust boundary”A repo’s .ccpod.yml ships with the codebase being run inside the sandbox, so it is treated as untrusted by default. The config walk (findProjectConfig in src/config/loader.ts) searches upward from the current directory but stops at the user’s home directory — a .ccpod.yml found above $HOME (e.g. at / on macOS) is not loaded, preventing a stray parent config from overriding profile settings for every child project. mergeConfigs enforces:
servicesfrom project are ignored entirely unless the profile setsallowProjectServices: true. A cloned repo could otherwise start arbitrary container images on the same bridge network as the credential-bearing main container. When opted in, the volume and port sanitization below still applies (unlessallowProjectHostMountsis also set).services[].volumesfrom project must be named volumes (<name>:<path>[:opts]). Host-path mounts (/foo,./foo,~/foo) are rejected.services[].portsfrom project may bind only to127.0.0.1/localhost/::1. Two-parthost:containerentries are auto-rewritten to127.0.0.1:host:container. Bracketed IPv6 is parsed explicitly so[::1]:host:containerloopback is accepted and every::-expanding wildcard ([::],[0::],[::0:0],[0:0:0:0:0:0:0:0], …) is rejected with an “all IPv6 interfaces” error.- top-level
ports.list(main container) and auto-detected.mcp.jsonports from project are published on127.0.0.1only.parsePortstags project-sourced mappings withhostIp: '127.0.0.1', which the runner renders as-p 127.0.0.1:<host>:<container>; profile-sourced ports carry nohostIpand keep Docker’s default0.0.0.0bind. envfrom project may not use${VAR}interpolation (see above) or set aPROJECT_ENV_DENYLISTkey (see above).CCPOD_*andDOCKER_*prefix keys are also blocked (see above).network:(policy/allow) is profile-owned. Projectnetworkkeys are ignored with a warning regardless ofmergestrategy, so a repo cannot downgrade arestrictedprofile tofullor extend its allow-list.init:from project is dropped (with a one-lineconsole.warn).- Project
.claude/,CLAUDE.mdand.claude/settings.jsonare ignored (with a warning) when they are symlinks, andpost-init.shis never copied from any config directory — it is generated only from trust-gatedinitcommands. profile:in.ccpod.ymlneeds one-time interactive approval per (project, profile), remembered in~/.ccpod/trusted-projects.json(0600). Non-interactive runs fail unless already approved;--profileskips the prompt.- A profile’s relative
image.dockerfileresolves against the profile directory, never the project.
To opt out, the profile may set allowProjectHostMounts: true (for sidecar volumes/ports), allowProjectInit: true (for init commands), allowProjectServices: true (for project-declared sidecar services), or list host variable names in allowProjectEnvForward: [NAME, ...] (bare NAME forwarding from project env). The booleans default to false; the list defaults to empty. There is no opt-out for the network or main-container port controls — they are always profile-owned.
Updater integrity
Section titled “Updater integrity”ccpod update requires each release to publish a SHASUMS256.txt asset alongside the binaries. The updater fetches SHASUMS256.txt and the platform asset in parallel, then streams the response body through createHash('sha256') into a write pipeline (node:stream/promises#pipeline). The hash is computed as bytes arrive, so the 50–80 MB binary is never buffered twice in memory; it is compared to the entry for the platform asset before the temp file is moved into place. A missing SHASUMS256.txt, a missing entry, or a mismatch all refuse the install with a clear error and leave nothing on disk.
The updater stages under the destination directory’s parent, streams and hashes the download, applies executable permissions, and atomically renames the verified file into place; any failure removes the staging directory without replacing the existing binary. The install.sh bootstrap performs the same verification: after downloading the binary it fetches SHASUMS256.txt, computes the digest with sha256sum (or shasum -a 256), and aborts on mismatch. It only warns-and-proceeds when the release definitively has no checksum asset (HTTP 404 — a release predating it). A mismatch, any other download error, a missing entry, or a missing sha256 tool aborts the install; set CCPOD_INSTALL_SKIP_VERIFY=1 to override the latter group explicitly.
Release pipeline
Section titled “Release pipeline”Releases are Changeset-driven and PR-gated. Merging PRs with .changeset/*.md files to main makes release.yml open or update a chore: version packages PR (version bump, CHANGELOG.md, lockfile). Merging that PR is the approval: the workflow re-runs bun run verify, cross-compiles the four platform binaries, writes SHASUMS256.txt, signs a build-provenance attestation for each binary (gh attestation verify <binary> --repo yorch/ccpod), creates the vX.Y.Z tag and GitHub release on the exact commit, then calls docker.yml to push the versioned GHCR image (a GITHUB_TOKEN-created tag would not trigger a tag-push workflow). The publish step only runs for commits from a merged changeset-release/* PR and refuses to replace the assets of an existing release. Nobody bumps package.json or pushes tags by hand.
Garbage collection (ccpod prune)
Section titled “Garbage collection (ccpod prune)”Over time, stopped containers, orphaned networks, and unreferenced plugin volumes accumulate. ccpod prune cleans them up:
- Stopped containers — any ccpod-labeled container that is not
running,paused, orrestartingis removed. Use--profileto restrict to one profile. - Orphaned networks —
ccpod-net-*networks with no attached endpoints are removed. - Unreferenced plugin volumes —
ccpod-plugins-<profile>volumes that no container references and whose profile no longer exists on disk are removed. With--profile, the profile-exists check is skipped (the caller explicitly wants that profile’s volume). Volume removal prompts for confirmation unless--forceis given. - Orphaned per-project state dirs —
~/.ccpod/state/<profile>/<projectHash>/directories with no remaining ccpod containers referencing that project hash are removed. Only applies tostateIsolation: per-projectprofiles. State dir removal prompts for confirmation unless--forceis given.
--dry-run lists what would be removed without executing any docker rm/network rm/volume rm/rm. The --profile flag is validated with PROFILE_NAME_REGEX before any Docker command is constructed.
State isolation (stateIsolation)
Section titled “State isolation (stateIsolation)”By default, persistent state is shared across all projects using the same profile (stateIsolation: per-profile). The state directory is ~/.ccpod/state/<profile>/, containing Claude’s projects/, todos/, and statsig/ directories.
When stateIsolation: per-project is set, each project gets its own state directory at ~/.ccpod/state/<profile>/<projectHash>/, where <projectHash> is the first 16 hex chars of SHA-256 over the canonical project path (same hash used for container names and network names). This prevents cross-project state leakage — conversation history, todos, and project metadata from one project are not visible to another project using the same profile.
ccpod state clear clears the current project’s state by default; --all clears all state for the profile. ccpod prune cleans orphaned per-project state dirs — those whose recorded project path (.ccpod-project marker) no longer exists; dirs without a marker are kept.
Profile name validation
Section titled “Profile name validation”Every CLI command that accepts --profile validates the name with validateProfileArg() (src/cli/validate.ts) before passing it to profileExists, getProfileDir, getStateDir, or any Docker filter. This prevents path traversal (../etc) and shell metacharacter injection through the profile name. The shared setup helper setupContainer also validates, covering ccpod run and ccpod shell. The validation uses the same PROFILE_NAME_REGEX (/^[a-zA-Z0-9_-]{1,64}$/) as the Zod schema in schema.ts.
Setup error handling
Section titled “Setup error handling”setupContainer (src/cli/commands/_setup.ts) throws Error on failure (missing profile, missing auth, missing OAuth credentials) instead of calling process.exit. The calling commands (run, shell) catch the error, print it, and exit. This makes the setup pipeline testable with await expect(...).rejects.toThrow(...). The global unhandledRejection/uncaughtException handler in cli/index.ts remains as a last-resort backstop.
Startup sequence
Section titled “Startup sequence”ccpod run [-- claude-args]│├─ 1. Detect container runtime (first matching socket wins)│ try: OrbStack (~/.orbstack/run/docker.sock)│ Docker ($DOCKER_SOCKET_PATH or /var/run/docker.sock, ~/.docker/run/docker.sock)│ Colima (~/.colima/default/docker.sock, ~/.colima/docker.sock)│ Podman ($XDG_RUNTIME_DIR/podman/podman.sock)│ error if none available│├─ 2. Resolve profile name│ --profile flag > .ccpod.yml profile: field > "default"│ if profile missing → trigger ccpod init, exit│├─ 3. Load + merge config│ load profile → sync config source if needed│ load .ccpod.yml (optional) → merge layers│ apply --no-state (forces state: ephemeral for this run)│ → ResolvedConfig│├─ 4. Ensure image│ if dockerfile: {{profile_dir}} placeholder expanded to ~/.ccpod/profiles/<profile>/│ tag = ccpod-local-<profile>-<sha256(dockerfile)>│ context = dirname(resolved dockerfile); relative paths anchor at the profile dir│ build if tag absent (or --rebuild)│ else: check locally; pull if absent│├─ 5. Ensure volumes│ credentials dir: mkdir -p ~/.ccpod/credentials/<profile>/│ plugins volume: docker volume create ccpod-plugins-<profile> (idempotent)│ state dir: mkdir -p ~/.ccpod/state/<profile>/ (if persistent)│ OR: --tmpfs /ccpod/state (if ephemeral)│├─ 6. Plugin install prep│ set CCPOD_PLUGINS_TO_INSTALL=<comma-list>│ entrypoint does delta-install (skips dirs that already exist)│├─ 7. Parse .mcp.json (if present and ports.autoDetectMcp: true)│ lstatSync rejects symlinks (untrusted project could point at host files)│ extract HTTP/SSE entries → additional port mappings│├─ 8. Start sidecars (if services: declared)│ validate dependency graph before Docker changes; stable topological order│ create network: ccpod-net-<sha256($PWD)>│ docker run -d --network <network> per service│ optional healthcheck readiness with bounded inspections and timeout│ rollback only IDs created by this invocation on failure│ labels: ccpod.profile, ccpod.project, ccpod.type=<service-name>│├─ 9. Build ContainerSpec│ image, workdir, env, mounts, network, ports, tty, labels│├─ 10. Start Claude container│ entrypoint assembles ~/.claude/│ plugin delta-install runs│ exec claude [args]│└─ 11. Attach interactive → raw TTY stdin/stdout/stderr headless → pipe stdout/stderr, capture exit codeTesting
Section titled “Testing”Config merging (merger.ts, schema.ts) are pure functions — fully unit-testable without Docker.
Container logic (builder.ts) is tested with a mock docker subprocess via mock.module().
Integration tests require a real Docker socket and a minimal test image.
tests/├── unit/ # 24 unit-test files; run without Docker│ ├── auth/resolver.test.ts│ ├── cli/config/{get,set}.test.ts│ ├── config/{loader,merger,permissions,schema,writer}.test.ts│ ├── container/{builder,runner}.test.ts│ ├── global/config.test.ts│ ├── image/{hash,manager}.test.ts│ ├── init/wizard.test.ts│ ├── mcp/parser.test.ts│ ├── profile/{exporter,git-sync,installer,installer.git,lock,manager}.test.ts│ ├── runtime/detector.test.ts│ └── update/{checker,updater}.test.ts├── integration/│ └── container/docker.test.ts # requires a real Docker socketRuntime socket paths
Section titled “Runtime socket paths”src/runtime/detector.ts tries these sockets in order until one is reachable. OrbStack is preferred over Docker because OrbStack also installs the standard /var/run/docker.sock symlink, so this order ensures the native OrbStack socket is chosen when both exist:
const RUNTIME_CANDIDATES = [ { name: 'orbstack', sockets: [ `${HOME}/.orbstack/run/docker.sock`, ], }, { name: 'docker', sockets: [ // Honors $DOCKER_SOCKET_PATH first, falling back to the default path DOCKER_SOCKET_PATH ?? '/var/run/docker.sock', `${HOME}/.docker/run/docker.sock`, ], }, { name: 'colima', sockets: [ `${HOME}/.colima/default/docker.sock`, `${HOME}/.colima/docker.sock`, ], }, { name: 'podman', sockets: [ `${XDG_RUNTIME_DIR}/podman/podman.sock`, `${HOME}/.local/share/containers/podman/machine/podman.sock`, ], },];Override the Docker socket path entirely by setting DOCKER_SOCKET_PATH in your environment (useful for non-standard Docker installs and tests).
Once a runtime is detected, src/runtime/docker.ts selects the CLI binary: podman when the runtime name is podman, docker for everything else. No podman-docker shim required.
Base image tags
Section titled “Base image tags”The ghcr.io/yorch/ccpod image is published by .github/workflows/docker.yml:
| Git event | Tags pushed |
|---|---|
Push to main | :main, :latest |
Push tag v1.2.3 | :1.2.3, :1.2 |
Pin to a specific release in your profile with image.use: ghcr.io/yorch/ccpod:1.2.3.