Skip to main content

CI/CD Workflows

Every service generated from a Ybor service archetype ships with a working CI/CD pipeline in .github/workflows/. This section documents what those workflows actually contain: every step, the action behind it, what it consumes, and what it produces.

The pipeline is deliberately uniform. Across all six supported languages the step sequence is identical, and only the toolchain steps (setup, version bump, build) differ. Everything from the container build onward is byte-for-byte the same file.

What You'll Learn

  • Which files the archetypes generate and where they come from
  • The exact step chain of the Build workflow, with inputs and outputs per step
  • The Cut Tag workflow and how a manual release differs from an automatic one
  • What happens after CI hands off to the platform repository (the CD side)
  • Which steps are shared and which are language-specific

Where the Workflows Come From

Service archetypes do not carry their workflow files directly. Each archetype composes a per-language CI library archetype, which renders .github/workflows/ into the generated project:

-- archetype.lua, near the end of every service archetype
local ci = require("java-ci")
ci.render(context, dest)

All three protocol variants of a language (REST, gRPC, GraphQL) pull the same CI library, so the protocol you pick has no effect on the pipeline:

LanguageArchetypesCI library
.NETdotnet-rest-service-archetype, dotnet-grpc-service-archetype, dotnet-graphql-service-archetypedotnet-ci-library
Gogolang-rest-service-archetype, golang-grpc-service-archetype, golang-graphql-service-archetypegolang-ci-library
Javajava-rest-service-archetype, java-grpc-service-archetype, java-graphql-service-archetypejava-ci-library
Pythonpython-rest-service-archetype, python-grpc-service-archetype, python-graphql-service-archetypepython-ci-library
Rustrust-rest-service-archetype, rust-grpc-service-archetype, rust-graphql-service-archetyperust-ci-library
TypeScripttypescript-rest-service-archetype, typescript-grpc-service-archetype, typescript-graphql-service-archetypetypescript-ci-library

Each CI library renders exactly two files:

.github/workflows/
├── build.yaml # push to any branch, and pull requests
└── cut-tag.yaml # manual minor/major releases
Retrofitting an existing application?

The *-service-empty-archetype overlays compose the same CI libraries, so they generate the same two files byte-for-byte. Everything on this page applies to them - but the context they run in differs in four ways. See Retrofit Overlays.

No promote workflow is generated

The archetypes do not render a promote.yaml. A freshly generated service deploys automatically to dev only. Promotion to stg and prd requires adding a promotion workflow yourself - see Promotion for the pattern and the dispatch inputs it needs.

The Build Workflow

build.yaml is the whole CI pipeline in a single job. It is triggered on every push to every branch and on pull requests:

on:
push:
branches: ["**"]
pull_request:

permissions:
contents: write
id-token: write

env:
IMAGE_NAME: billing-service
APPLICATION_NAME: billing-service
ElementValueWhy
on.push.branches["**"]Every branch gets compiled and containerized, so a branch never merges untested
on.pull_requestall PRsCovers PRs from forks, where the push trigger does not fire in the base repo
permissions.contentswriteThe version bump pushes a commit and a tag; the release step creates a GitHub release
permissions.id-tokenwriteRequired for OIDC token exchange inside the cut-tag actions (see Authentication)
env.IMAGE_NAMEproject nameContainer image name inside the Artifactory repository
env.APPLICATION_NAMEproject namedirectory-name in the platform repository, so kubernetes/<name>/
Same-repo PRs run the workflow twice

Because both push and pull_request are configured, a branch pushed inside the repository and then opened as a PR produces two runs. Neither publishes anything (see the branch matrix below), so the duplication costs runner minutes, not correctness.

Branch Behavior

Roughly half the steps are gated on github.ref_name == 'main'. The result is two distinct modes:

BehaviorOn mainOn any other branch / PR
Compile, lint, testYesYes
Cut a patch version and tagYesSkipped
Docker image builtYesYes
Docker image pushed to ArtifactoryYes (push: true)No (push: false)
digest.txt artifactYesSkipped
GitHub release createdYesSkipped
Manifest dispatch to platform repoYesSkipped

A feature-branch run is therefore a full dry run: it proves the code compiles, the tests pass, and the production Dockerfile builds for both architectures, without producing anything the platform can deploy.

Step Chain

The full sequence, in order. "Shared" means the step is identical across all six languages.

#StepScopeRuns on
1CheckoutShared (Go adds fetch-depth: 0)Always
2Toolchain setupLanguage-specificAlways
3Package repository loginPython onlyAlways
4Install protocRust onlyAlways
5Cut Patch VersionLanguage-specific action, shared shapemain
6BuildLanguage-specificAlways
7Login to Artifactory Container RegistrySharedmain
8Set up Docker BuildxSharedAlways
9Build and Publish Docker ImageSharedAlways
10Make ArtifactsSharedmain
11Create Github releaseSharedmain
12Update Application ManifestSharedmain

Steps 2 through 6 are covered on the per-language pages. Steps 1 and 7 through 12 are documented below once.

Shared Steps

1. Checkout

- uses: actions/checkout@v4
InputsNone passed. Defaults apply: shallow clone (fetch-depth: 1), authenticated with the job's GITHUB_TOKEN
OutputsThe repository working tree at ${{ github.workspace }}

Go overrides this with fetch-depth: 0 because golang-cut-tag derives the next version from the latest git tag rather than from a version field in a manifest file - the tags are the version history, so it needs all of them.

7. Login to Artifactory Container Registry

- name: Login to Artifactory Container Registry
if: github.ref_name == 'main'
uses: p6m-actions/docker-repository-login@v1
with:
registry: ${{ vars.P6M_ARTIFACTORY_HOSTNAME }}
username: ${{ secrets.P6M_ARTIFACTORY_USERNAME }}
password: ${{ secrets.P6M_ARTIFACTORY_IDENTITY_TOKEN }}

p6m-actions/docker-repository-login wraps docker/login-action@v2.

InputRequiredSource
registryYesvars.P6M_ARTIFACTORY_HOSTNAME
usernameYessecrets.P6M_ARTIFACTORY_USERNAME
passwordYessecrets.P6M_ARTIFACTORY_IDENTITY_TOKEN

Outputs: none. The effect is ambient - a credential entry written into the runner's Docker config that the later push step relies on. Because the step is gated on main, off-main runs have no registry credentials, which is exactly why step 9 sets push: false there.

8. Set up Docker Buildx

- name: Set up Docker Buildx
id: buildx
uses: p6m-actions/docker-buildx-setup@v1

p6m-actions/docker-buildx-setup wraps docker/setup-buildx-action@v2.

InputsNone
Outputsname - the buildx instance name, consumed by step 9 as builder-name
driver - the buildx driver in use
endpoint - the builder endpoint

This step runs on every branch, because multi-arch building is needed whether or not the result is pushed.

9. Build and Publish Docker Image

- name: Build and Publish Docker Image
id: docker-publish
uses: p6m-actions/docker-buildx-build-publish@v1
with:
dockerfile-path: .platform/docker/prd/Dockerfile
image-name: ${{ env.IMAGE_NAME }}
image-tag: ${{ steps.cut-patch.outputs.tag || format('dev-{0}', github.sha) }}
registry: ${{ vars.P6M_ARTIFACTORY_HOSTNAME }}/${{ vars.P6M_ARTIFACTORY_PROJECT }}-docker-local/applications
platforms: linux/amd64,linux/arm64
push: ${{ github.ref_name == 'main' }}
skip-setup: true
builder-name: ${{ steps.buildx.outputs.name }}

p6m-actions/docker-buildx-build-publish wraps docker/build-push-action@v5. This is the pivot of the whole pipeline: its image-digest output is what CD ultimately deploys.

InputValue in the generated workflowNotes
dockerfile-path.platform/docker/prd/DockerfileThe production Dockerfile the archetype renders. A separate .platform/docker/local/Dockerfile exists for Tilt and is not used here
context-path(default .)Repository root
image-name${{ env.IMAGE_NAME }}The project name
image-tagsteps.cut-patch.outputs.tag or dev-<sha>On main, the tag just cut. Elsewhere cut-patch was skipped so its output is empty and the fallback applies
registry<hostname>/<project>-docker-local/applicationsFull Artifactory Docker repository path
platformslinux/amd64,linux/arm64See Multi-Arch Builds
push${{ github.ref_name == 'main' }}Build-only on branches, build-and-push on main
skip-setuptrueBuildx was already set up in step 8; skipping avoids a redundant second setup
builder-name${{ steps.buildx.outputs.name }}Reuses the builder from step 8
cache-from / cache-to(defaults type=gha / type=gha,mode=max)GitHub Actions layer cache
OutputDescription
image-digestThe immutable sha256:... content digest. This is the value the entire CD path keys on
image-metadataThe raw build result metadata JSON from buildx
image-uriThe full tagged URI, <registry>/<image-name>:<image-tag>
image-digest is empty when push is false

docker/build-push-action only produces a digest when the image is pushed to a registry. On feature branches the digest output is blank - which is harmless, because every step that consumes it is gated on main.

10. Make Artifacts

- name: Make Artifacts
if: github.ref_name == 'main'
run: |
echo '${{ steps.docker-publish.outputs.image-digest }}' | tee digest.txt
Inputssteps.docker-publish.outputs.image-digest
Outputsdigest.txt in the workspace, containing the bare digest string; the digest is also echoed into the run log

digest.txt exists so that a later promotion to stg or prd can recover the exact digest from the GitHub release without re-reading the registry. See Promotion.

11. Create Github release

- name: Create Github release
if: github.ref_name == 'main'
uses: ncipollo/release-action@v1
with:
name: Version ${{ steps.cut-patch.outputs.version }}
tag: ${{ steps.cut-patch.outputs.tag }}
makeLatest: true
artifacts: "digest.txt"
removeArtifacts: true
generateReleaseNotes: true
body: |
Application version: `${{ steps.cut-patch.outputs.version }}`
Docker image digest: `${{ steps.docker-publish.outputs.image-digest }}`

This is the one third-party action in the pipeline: ncipollo/release-action.

InputValueEffect
nameVersion <version>Release title
tagsteps.cut-patch.outputs.tagThe tag created in step 5
makeLatesttrueMarks this release as "Latest"
artifactsdigest.txtUploads the digest file produced in step 10
removeArtifactstrueClears previously attached assets before uploading
generateReleaseNotestrueGitHub generates the commit/PR changelog
bodyversion + digestPrepended to the generated notes, so the digest is human-readable on the release page

Outputs: the action exposes id, html_url, and upload_url. The generated workflow does not consume them.

See GitHub Releases for how these releases are consumed downstream.

12. Update Application Manifest

- name: Update Application Manifest
if: github.ref_name == 'main'
uses: p6m-actions/platform-application-manifest-dispatch@v1
with:
repository: ${{ github.repository }}
image-name: ${{ env.IMAGE_NAME }}
environment: "dev"
digest: ${{ steps.docker-publish.outputs.image-digest }}
update-manifest-token: ${{ secrets.P6M_UPDATE_MANIFEST_TOKEN }}
platform-dispatch-url: ${{ vars.P6M_PLATFORM_DISPATCH_URL }}
directory-name: ${{ env.APPLICATION_NAME }}

The final CI step and the handoff to CD. p6m-actions/platform-application-manifest-dispatch POSTs a repository_dispatch event of type update-digest to the platform repository.

InputRequiredValuePurpose
repositoryYes${{ github.repository }}owner/repo of the application; becomes the remote Kustomize base
image-nameYes${{ env.IMAGE_NAME }}Image name the platform side rewrites
directory-nameYes${{ env.APPLICATION_NAME }}Target path kubernetes/<directory-name>/ in the platform repo
environmentYes"dev"Environment subdirectory. Hardcoded to dev in the generated workflow
digestYesstep 9's image-digestThe digest to pin
update-manifest-tokenYessecrets.P6M_UPDATE_MANIFEST_TOKENAuthorizes the dispatch
platform-dispatch-urlYesvars.P6M_PLATFORM_DISPATCH_URLThe platform repo's dispatch endpoint
registryNo(default default)default for Artifactory, ecr for AWS ECR
resource-directory-nameNo(default empty)Points the Kustomize base at a different repo directory when set
OutputDescription
statussuccess, or the HTTP status code on failure (the step then fails the job)

The step also writes a summary table to $GITHUB_STEP_SUMMARY showing repository, image name, directory name, environment, registry, and digest - the fastest way to confirm what was dispatched.

The Cut Tag Workflow

cut-tag.yaml exists because build.yaml only ever cuts patch versions. Minor and major bumps are a deliberate act:

on:
workflow_dispatch:
inputs:
version-level:
description: "Version bump level"
required: true
default: "patch"
type: choice
options: [patch, minor, major]

permissions:
id-token: write
contents: write

jobs:
cut-tag:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}

- uses: p6m-actions/<language>-setup@v1

- uses: p6m-actions/<language>-cut-tag@v1
with:
version-level: ${{ inputs.version-level }}
Triggerworkflow_dispatch only - run it from the Actions tab
Inputversion-level: patch, minor, or major
OutputsA version bump commit and an annotated git tag pushed to the branch you ran it from

The workflow only cuts the tag. It does not build an image or dispatch a manifest update. Pushing the tag and bump commit is what causes build.yaml to run and produce the artifacts - except that the bump commit carries [skip ci], so in practice a cut-tag run produces a tag, and the next merge to main is what publishes. See Versioning for the full versioning model.

The checkout here uses fetch-depth: 0 in every language, because a minor or major bump needs the full tag history to validate that the target tag does not already exist.

Authentication and the [skip ci] Hook

Every cut-tag action except Go's runs p6m-actions/token-exchange@v2 first. That action:

  1. Requests an OIDC token from GitHub Actions (this is what permissions: id-token: write is for).
  2. Exchanges it at https://auth.p6m.dev/api/github/actions/token-exchange for a P6M GitHub App installation token.
  3. Sets GITHUB_TOKEN / GH_TOKEN and configures git to push as p6m-ybor[bot].
  4. Installs a prepare-commit-msg git hook that prepends [skip ci] to every commit message.

Step 4 matters: an App token does trigger workflows, so without the hook the version bump commit pushed to main would re-trigger build.yaml, which would cut another patch, and so on. The hook is what breaks that loop.

Go is the exception. golang-cut-tag never writes a version into a file and never creates a commit - a Go module has no version field, so the tag alone is the version. With no commit there is no loop to break, and the tag is pushed with the workflow's own GITHUB_TOKEN, which by design does not trigger further workflow runs.

The CD Side

CI ends at the dispatch. Here is what happens next.

build.yaml (app repo)          .platform (platform repo)         Cluster
───────────────────── ───────────────────────── ───────
image pushed

└─ digest ──dispatch──► Update Image Digest workflow

├─ writes kubernetes/<app>/<env>/kustomization.yaml
├─ kustomize edit set image ...@<digest>
├─ kustomize edit set annotation ...
└─ commit "[skip ci] Update ..." ──► ArgoCD syncs ──► rollout

Platform Step 1: Receive the Dispatch

The organization's .platform repository carries a platform-managed workflow that listens for the event:

# .platform/.github/workflows/update-kustomize.yaml
name: Update Image Digest
on:
repository_dispatch:
types: [update-digest]

jobs:
update-image-digest:
uses: "p6m-dev/github-actions/.github/workflows/update-kustomize.yaml@main"
InputsThe client_payload from the dispatch: repository, directory_name, image_name, environment_dir, digest, registry, resource_directory_name
OutputsDelegates entirely to the reusable workflow below
Do not edit this file

update-kustomize.yaml is managed by the platform and carries a "DO NOT EDIT" header. Changes are overwritten.

Platform Step 2: Rewrite the Kustomization

The reusable workflow runs under a concurrency group of <directory_name>-<environment_dir> with cancel-in-progress: true, so a newer digest for the same app and environment supersedes an in-flight older one.

It normalizes directory_name and the repository owner to lowercase kebab-case, creates kubernetes/<directory_name>/<environment_dir>/, and writes a kustomization.yaml whose only resource is a remote reference back to the application repository:

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

resources:
- https://github.com/<owner>/<app-repo>/.platform/kubernetes/dev

This is the link between the two repos: the application repo owns what the deployment looks like (its .platform/kubernetes/ tree, rendered by the archetype), and the platform repo owns which image version is live.

It then pins the image by digest:

kustomize edit set image "<image>=<hostname>/<owner>-docker-local/applications/<image>@<digest>"
kustomize edit set image "<image>-server=<hostname>/<owner>-docker-local/applications/<image>-server@<digest>"
kustomize edit set annotation meta.p6m.dev/github-repository:<owner>/<app-repo>
kustomize edit set annotation meta.p6m.dev/last-updated:<epoch seconds>
InputSourceEffect
client_payload.directory_namedispatchNormalized, becomes kubernetes/<name>/
client_payload.environment_dirdispatchEnvironment subdirectory, and the remote base's environment overlay
client_payload.repositorydispatchThe remote Kustomize base, unless resource_directory_name overrides it
client_payload.image_namedispatchImage to rewrite; falls back to the normalized directory name when empty
client_payload.digestdispatchPinned with @sha256:...
client_payload.registrydispatchdefault builds an Artifactory path, ecr builds an ECR path, anything else is a no-op
vars.P6M_ARTIFACTORY_HOSTNAMEplatform repoRegistry hostname in the rewritten image reference
OutputDescription
kubernetes/<app>/<env>/kustomization.yamlThe updated, digest-pinned overlay
Two meta.p6m.dev annotationsSource repository and last-updated timestamp, for traceability
Both <image> and <image>-server are rewritten

The workflow sets the image twice, once bare and once with a -server suffix, so that both single-container services and archetypes that name the runtime container <app>-server are covered by one dispatch.

Platform Step 3: Commit

- name: Commit Changes
uses: p6m-actions/p6m-release-action@v2
with:
tags: ""
commit_message: "[skip ci] Update <app> image digest to <digest>"
InputsThe staged kustomization.yaml, plus PLATFORM_TOKEN_EXCHANGE_URL and PLATFORM_INSTALLATION_ID for App-token authentication
OutputsA commit on the platform repo's main branch. No tags are created

The preceding step stages with git add --intent-to-add, which is what keeps p6m-release-action from sweeping unrelated working-tree changes into the commit.

Platform Step 4: ArgoCD Sync

ArgoCD watches the platform repository, notices the new commit, renders the Kustomize overlay (which pulls the application repo's .platform/kubernetes/<env> as a remote base), and applies the resulting PlatformApplication resource. The Platform Application Operator expands that into the concrete Kubernetes objects and the rollout begins.

InputsThe platform repo commit; the application repo's .platform/kubernetes/<env> overlay
OutputsA running workload on the digest-pinned image

See ArgoCD and Platform Folder Anatomy for the deployment side in detail.

Language Differences at a Glance

Everything not listed here is identical across all six languages.

.NETGoJavaPythonRustTypeScript
Setup actiondotnet-setupgolang-setupjava-maven-setuppython-uv-setuprust-setupjs-pnpm-setup
Build actiondotnet-buildgolang-buildjava-maven-buildpython-uv-buildrust-buildjs-pnpm-build
Cut-tag actiondotnet-cut-taggolang-cut-tagjava-maven-cut-tagpython-uv-cut-tagrust-cut-tagjs-pnpm-cut-tag
Version lives inDirectory.Build.propsgit tags onlypom.xmlpyproject.tomlCargo.tomlpackage.json
Extra step---Package repository loginapt-get install protobuf-compiler-
fetch-depth: 0 in buildNoYesNoNoNoNo
Build-step overridesrun-tests, publish-artifactsnonerun-test, build-commandnonenonenone
Version-bump commitYesNoYesYesYesYes
Uses token-exchangeYesNoYesYesYesNo (p6m-release-action)

Per-Language Detail

And for retrofitting the platform layer onto an application that already exists:

Required Secrets and Variables

The generated workflows reference these directly. All are provisioned by the platform during onboarding.

NameKindUsed by
P6M_ARTIFACTORY_HOSTNAMEVariableRegistry login, image registry path
P6M_ARTIFACTORY_PROJECTVariableImage registry path
P6M_PLATFORM_DISPATCH_URLVariableManifest dispatch
P6M_ARTIFACTORY_USERNAMESecretRegistry login, Python package login
P6M_ARTIFACTORY_IDENTITY_TOKENSecretRegistry login, Python package login
P6M_UPDATE_MANIFEST_TOKENSecretManifest dispatch

See Platform Secrets & Variables if your organization still uses the older unprefixed names.