Vortos
Pipeline

Pipeline

A provider-agnostic CI/CD pipeline model — native ARM64 builds, fully digest-pinned actions, and OIDC instead of standing registry secrets.

Pipeline

vortos-pipeline doesn't run your builds — it generates the workflow files that do, from a single provider-agnostic model. Write your pipeline once as a Pipeline model object; an emitter renders it into the YAML a specific CI provider understands. The model is what you maintain; the YAML is what gets checked in and regenerated, not hand-edited.

Why generate instead of hand-write YAML

Hand-written GitHub Actions YAML drifts: someone edits the workflow file directly, the next person's "fix" conflicts with it, and nobody notices the build matrix and the deploy matrix have quietly diverged. pipeline:verify exists to catch exactly this — it regenerates from the model and diffs against what's committed.

The model

PipelineDefinition  (what to build, which architectures, OIDC on/off)

    ▼ PipelineBuilder
Pipeline  (Stage[] — test, static-analysis, agnosticism, build, deploy, split)

    ▼ PipelineEmitterInterface (Ops Kit driver)
EmittedArtifactSet  (the actual workflow file contents)

PipelineBuilder assembles stages from a PipelineDefinition and a StageGate. Some stages (Test, StaticAnalysis, Agnosticism, Deploy, Split) always emit; others are gated behind enabledFutureStages so a stage can exist in the model before its supporting tooling is ready everywhere, without forcing every consumer onto it immediately.

Configuring the pipeline (config/pipeline.php)

The PipelineDefinition is the single source of truth. It is built by PipelineDefinitionFactory from environment variables plus an optional config/pipeline.php, and injected into both pipeline:generate and pipeline:verify — so the model you configure is exactly the model that gets emitted and drift-checked. (You no longer override the service, and the commands no longer construct a throwaway default.)

config/pipeline.php
use Vortos\Pipeline\Model\ServiceContainer;

return [
    // Emit to deploy.yml with `name: Deploy` instead of clobbering an existing ci.yml
    'workflow_filename' => 'deploy.yml',
    'workflow_name'     => 'Deploy',

    // The generated pipeline can BE your CI: real test command, service containers, extra steps
    'test_command'    => './vendor/bin/phpunit --testsuite=Unit',
    'analyse_command' => './vendor/bin/phpstan analyse --level=9',
    'test_service_containers' => [
        new ServiceContainer(
            name: 'postgres',
            image: 'postgres:18-alpine',
            ports: ['5432:5432'],
            env: ['POSTGRES_PASSWORD' => 'test'],
            options: ['--health-cmd=pg_isready'],
        ),
    ],
    'test_steps' => [
        ['name' => 'Run migrations', 'run' => 'bin/console vortos:migrate --no-interaction'],
        ['name' => 'Contract check', 'run' => 'bin/console vortos:contracts:check'],
    ],

    'image_repository'  => 'ghcr.io/acme/app',
    'registry_provider' => 'ghcr',

    // Trigger + deploy-gate branch (defaults to 'main')
    'deployment_branch' => 'main',

    // Deploy-on-target coordinates (where the image runs on the VPS)
    'remote_deploy_dir' => '/opt/vortos',
    'app_network'       => 'vortos-net',

    // Runtime files the cutover compose (run inside the one-shot) needs mounted:
    'runtime_env_files'       => ['/opt/vortos/.env.prod'],  // must match config/deploy.php envFiles
    'runtime_file_secret_dirs' => [],                        // tmpfs dirs for file-shaped secrets (G8)

    // Setup steps injected into EVERY container-booting job (test, static-analysis,
    // agnosticism) — e.g. provide an env file so the DI container can compile %env()% refs
    'bootstrap_steps' => [
        ['name' => 'Prepare env', 'run' => 'cp .env.example .env'],
    ],

    // Turn off quality stages an app doesn't have (both default true)
    'emit_static_analysis' => true,
    'emit_agnosticism'     => true,
];

Config wins over env

Scalars can also be set via env (PIPELINE_WORKFLOW_FILENAME, PIPELINE_TEST_COMMAND, PIPELINE_IMAGE_REPOSITORY, …) for CI overrides. Structured settings (service containers, test steps) come from config/pipeline.php, which takes precedence. Service containers render as GitHub Actions services: on the test job; test_steps run after composer install and before the test command.

Quality-stage modes

A quality stage (static analysis, agnosticism lint) is declarative — beyond on/off it has three behaviours, set on the builder form of config/pipeline.php:

use Vortos\Pipeline\Definition\QualityMode;

return static fn ($pipeline) => $pipeline
    ->staticAnalysisMode(QualityMode::Warn)   // run if the tool is installed; surface issues as
                                              // GitHub warnings; never fail the build; skip cleanly
                                              // if the tool is absent
    ->agnosticismMode(QualityMode::Enforce);  // default: run it and fail the build on issues

QualityMode::Off omits the stage entirely (equivalent to emit_* => false), and a job that would have depended on it drops the dependency so the workflow still runs. Use Warn while an app is adopting a tool it doesn't have installed yet, rather than shipping a red pipeline.

Pinned actions — no floating tags

Every third-party GitHub Action referenced anywhere in a generated workflow is represented as a PinnedAction, and the constructor refuses anything that isn't a full 40-character commit SHA:

final readonly class PinnedAction
{
    public function __construct(
        public string $owner,
        public string $repo,
        public string $sha,
        public string $versionComment,
    ) {
        if (preg_match('/^[0-9a-f]{40}$/', $sha) !== 1) {
            throw UnpinnedActionException::forSha($sha);
        }
    }
}
# generated output — the SHA is what actually runs; the comment is for humans
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5

A tag like @v4 can be force-moved by the action's maintainer (or an attacker who compromises their account) to point at different code without you ever seeing a diff. A pinned SHA cannot change without you noticing — pipeline:verify would immediately flag it.

Native ARM64 builds

BuildMode distinguishes two ways to produce an ARM64 image:

enum BuildMode: string
{
    case Native = 'native';        // build directly on an arm64 runner
    case BuildxQemu = 'buildx-qemu'; // emulate arm64 on an amd64 runner
}

QEMU emulation works but is slow and, more importantly, hides architecture-specific build failures until they hit production. BuildMode::Native generates a workflow matrix that runs the ARM64 build on an actual ARM64 runner — slower to set up, but the build behaves like the real deployment target from the first CI run, not just at release time.

Architecture verification

After a multi-arch image is pushed, ArchAssertionScript generates a verification step that inspects the published manifest and fails the build if the architecture doesn't match what was requested:

MANIFEST=$(docker manifest inspect ghcr.io/org/app:sha-abc123)
ARCH=$(echo "$MANIFEST" | grep -o '"architecture"...')
if [ "$ARCH" != "arm64" ] || [ "$OS" != "linux" ]; then
  echo "::error::Architecture mismatch: expected linux/arm64, got linux/$ARCH"
  exit 1
fi

This closes a specific failure mode: a buildx configuration mistake that silently produces an amd64 image tagged as the arm64 variant. Without this check, that image runs (badly, or not at all) only once it's pulled onto real ARM64 hardware — by which point you're debugging in production. With it, the build fails in CI, immediately, with the exact mismatch named.

Reproducible base image

If PipelineDefinition::baseImageDigest is set, it's passed to the build as the BASE_IMAGE_DIGEST build-arg verbatim. If it's left null, the pipeline no longer just warns — it emits a Resolve base image digest step that reads the final FROM from your Dockerfile, resolves its current digest with docker buildx imagetools inspect, and exports BASE_IMAGE_DIGEST into the build environment, so the build is pinned to an immutable digest without you hand-maintaining a sha256: in config. The step is non-fatal: if the base image can't be resolved (offline, no resolvable FROM), it emits a ::warning:: and the build proceeds unpinned. An explicit baseImageDigest always wins.

Multi-registry support

The build stage login step is generated by a CiRegistryLoginProviderInterface driver selected via registryProvider on PipelineDefinition. Three providers ship out of the box:

registryProviderRegistryCredentials needed
ghcr (default)GitHub Container Registry (ghcr.io)Built-in GITHUB_TOKEN — no extra secrets
docker-hubDocker Hub (docker.io)DOCKER_USERNAME + DOCKER_TOKEN repository secrets
gcp-artifact-registryGCP Artifact RegistryGCP_SA_KEY repository secret (service account JSON)
// GHCR — default, no extra secrets required
new PipelineDefinition(imageRepository: 'ghcr.io/org/app');

// Docker Hub
new PipelineDefinition(
    imageRepository: 'docker.io/org/app',
    registryProvider: 'docker-hub',
);

// GCP Artifact Registry — registryHost extracted from imageRepository automatically
new PipelineDefinition(
    imageRepository: 'europe-west4-docker.pkg.dev/my-proj/my-repo/app',
    registryProvider: 'gcp-artifact-registry',
);

Each provider uses the pinned docker/login-action SHA (the same as KnownActionFactory::dockerLogin()) and declares only the job permissions it actually needs. GHCR requests packages: write; Docker Hub and GCP declare no additional permissions beyond the base contents: read.

Custom providers are additive — implement CiRegistryLoginProviderInterface, tag it #[AsDriver('my-registry')], and select it with registryProvider: 'my-registry'.

OIDC instead of standing registry secrets

For GHCR (the default), set oidc: true and the generated workflow requests the id-token: write permission instead of relying on a long-lived registry password stored as a repository secret:

new PipelineDefinition(imageRepository: 'ghcr.io/org/app', oidc: true);
permissions:
  id-token: write
  contents: read
  packages: write   # from GhcrCiLoginProvider::requiredPermissions()

The workflow exchanges a short-lived OIDC token for registry access at build time — there's no REGISTRY_PASSWORD secret sitting in the repository's settings for anyone (or any compromised dependency with access to secrets) to exfiltrate. This is the same zero-standing-secrets principle Deploy's credential providers apply to deploy-time access, applied to the build pipeline itself.

The OIDC default derives from your deploy posture

When you don't set oidc explicitly, it defaults from the deploy credential posture — posture: DeployPosture::SshCaOidc (or deploy_posture: 'ssh-ca-oidc' / PIPELINE_DEPLOY_POSTURE) is the only posture that turns keyless OIDC on. The ssh-key (age-KEK deploy-in-image) and pull-agent postures default to oidc: false, and an unknown/custom credential also defaults off. Keep posture aligned with your config/deploy.php credential. This prevents the footgun where merely setting an imageRepository used to emit a keyless deploy job that an ssh-key deploy could never satisfy. An explicit oidc() always wins.

The generated deploy job

When a build stage is emitted, the deploy job is deploy-on-target: the runner opens SSH to the VPS and runs vortos:deploy:provisionvortos:release:record-manifestdeploy:doctordeploy inside the pulled image, on the VPS, attached to the app's Docker network (app_network). Provisioning runs first so its vortos:migrate step creates the release-ledger schema before record-manifest writes to it — on a fresh production database the reverse order died with relation … does not exist. Two problems dissolve at once: the control-plane commands reach the production release-ledger DB by construction (it is a service on that network), and the arm64-native image runs on the arm64 host — the runner only runs an ssh client and never docker runs the image, so there is no runner/image architecture mismatch. The one-shots reach Docker only through the box's docker-socket-proxy (DOCKER_HOST), never a raw socket. The deploy job runs on ubuntu-latest and requests id-token: write only when oidc: true.

Because the cutover runs docker compose up for the color from inside the one-shot, the generated docker run also bind-mounts each runtime_env_files path read-only at its absolute path — the compose it generates references those as env_file:, so they must exist inside the container. When file-shaped secrets are declared (runtime_file_secret_dirs), the job creates the tmpfs dirs, mounts them, and runs vortos:deploy:materialize-file-secrets just before cutover.

The generated secrets follow your credential posture. On the OIDC path the deploy job references no standing secret other than the built-in GITHUB_TOKEN (used for the VPS registry pull); the SSH credential is a short-lived certificate federated from the runner's id-token against a CA you configure via non-secret vars.* — an architecture test fails the build if any other secret leaks in. On the ssh-key path (oidc: false) the runner authenticates with secrets.VORTOS_DEPLOY_SSH_KEY and forwards the age KEK VORTOS_AGE_IDENTITY over the encrypted channel; the age-encrypted store is delivered to the deploy dir (mode 0640) and mounted read-only into the container, which is granted the store's group via --group-add so the image's runtime user can read the ciphertext. Connection coordinates (vars.VORTOS_DEPLOY_HOST / _USER / _PORT) and vars.VORTOS_DEPLOY_KNOWN_HOSTS are non-secret.

Registry authentication on the box is provider-driven. Before pulling, the remote script emits a real docker login for the configured registry_provider — GHCR (via GITHUB_TOKEN), Docker Hub (via secrets.DOCKER_USERNAME / secrets.DOCKER_TOKEN), or GCP Artifact Registry — so a private image is never pulled unauthenticated.

Permissions are explicit and minimal

Permissions rejects duplicate scopes at construction and defaults to read-only:

Permissions::readOnly(); // contents: read — and nothing else, unless you add it
$permissions = Permissions::readOnly()
    ->with(new Permission(PermissionScope::IdToken, PermissionAccess::Write))
    ->with(new Permission(PermissionScope::Packages, PermissionAccess::Write));

A generated workflow never has a broader permission than the stages it actually runs require — there's no permissions: write-all shortcut available in the model.

CLI

# generate workflow files from the model
php bin/console pipeline:generate --emitter=github

# preview without writing anything
php bin/console pipeline:generate --emitter=github --dry-run

# overwrite existing workflow files
php bin/console pipeline:generate --emitter=github --force

# detect drift between the model and what's actually committed
php bin/console pipeline:verify --emitter=github

# verify every pinned action SHA exists upstream AND runs on a supported runtime (fail-closed; needs network)
php bin/console pipeline:actions:verify --json

pipeline:verify regenerates the workflow in memory and diffs it against the committed file — run it in CI on every PR so a manually edited workflow file is caught the same run it's introduced, not discovered weeks later when the build matrix and the model have drifted apart.

pipeline:actions:verify resolves each pinned action SHA against the GitHub API and fails closed if any pin no longer exists — the check that catches a stale or wrong pin before it breaks a run. It also reads each action's runs.using runtime and fails closed if any pin runs on a runtime GitHub has removed (node16/node12); a merely deprecated runtime (node20) is reported as an advisory rather than a failure (for actions whose latest major is still node20 with no node24 release). The shipped pins all run on node24. It is network-dependent, so wire it into CI (it honours GITHUB_TOKEN); an offline structural check runs in the package's own test suite regardless.

Emitters

PipelineEmitterInterface is the Ops Kit port. GitHubActionsEmitter is the shipped driver, rendering the provider-agnostic Pipeline model into .github/workflows/*.yml. Because the model itself never mentions GitHub-specific concepts directly in the core builder, a second emitter for a different CI provider is additive — implement the port, register #[AsDriver('...')], and --emitter=<key> selects it.

Step-by-step: adding native ARM64 to your build

Declare the architecture in your pipeline definition:

$definition = new PipelineDefinition(
    imageRepository: 'ghcr.io/org/app',
    architectures: [Arch::Amd64, Arch::Arm64],
    buildMode: BuildMode::Native,
    oidc: true,
);

Regenerate the workflow:

php bin/console pipeline:generate --emitter=github --force

Commit the generated .github/workflows/*.yml alongside the model change — never hand-edit it afterward.

Wire pipeline:verify into your own CI as a check on every PR, so drift between the model and the committed workflow fails the build instead of accumulating silently.

On this page