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
Buildworkflow, with inputs and outputs per step - The
Cut Tagworkflow 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:
| Language | Archetypes | CI library |
|---|---|---|
| .NET | dotnet-rest-service-archetype, dotnet-grpc-service-archetype, dotnet-graphql-service-archetype | dotnet-ci-library |
| Go | golang-rest-service-archetype, golang-grpc-service-archetype, golang-graphql-service-archetype | golang-ci-library |
| Java | java-rest-service-archetype, java-grpc-service-archetype, java-graphql-service-archetype | java-ci-library |
| Python | python-rest-service-archetype, python-grpc-service-archetype, python-graphql-service-archetype | python-ci-library |
| Rust | rust-rest-service-archetype, rust-grpc-service-archetype, rust-graphql-service-archetype | rust-ci-library |
| TypeScript | typescript-rest-service-archetype, typescript-grpc-service-archetype, typescript-graphql-service-archetype | typescript-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
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.
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
| Element | Value | Why |
|---|---|---|
on.push.branches | ["**"] | Every branch gets compiled and containerized, so a branch never merges untested |
on.pull_request | all PRs | Covers PRs from forks, where the push trigger does not fire in the base repo |
permissions.contents | write | The version bump pushes a commit and a tag; the release step creates a GitHub release |
permissions.id-token | write | Required for OIDC token exchange inside the cut-tag actions (see Authentication) |
env.IMAGE_NAME | project name | Container image name inside the Artifactory repository |
env.APPLICATION_NAME | project name | directory-name in the platform repository, so kubernetes/<name>/ |
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:
| Behavior | On main | On any other branch / PR |
|---|---|---|
| Compile, lint, test | Yes | Yes |
| Cut a patch version and tag | Yes | Skipped |
| Docker image built | Yes | Yes |
| Docker image pushed to Artifactory | Yes (push: true) | No (push: false) |
digest.txt artifact | Yes | Skipped |
| GitHub release created | Yes | Skipped |
| Manifest dispatch to platform repo | Yes | Skipped |
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.
| # | Step | Scope | Runs on |
|---|---|---|---|
| 1 | Checkout | Shared (Go adds fetch-depth: 0) | Always |
| 2 | Toolchain setup | Language-specific | Always |
| 3 | Package repository login | Python only | Always |
| 4 | Install protoc | Rust only | Always |
| 5 | Cut Patch Version | Language-specific action, shared shape | main |
| 6 | Build | Language-specific | Always |
| 7 | Login to Artifactory Container Registry | Shared | main |
| 8 | Set up Docker Buildx | Shared | Always |
| 9 | Build and Publish Docker Image | Shared | Always |
| 10 | Make Artifacts | Shared | main |
| 11 | Create Github release | Shared | main |
| 12 | Update Application Manifest | Shared | main |
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
| Inputs | None passed. Defaults apply: shallow clone (fetch-depth: 1), authenticated with the job's GITHUB_TOKEN |
| Outputs | The 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.
| Input | Required | Source |
|---|---|---|
registry | Yes | vars.P6M_ARTIFACTORY_HOSTNAME |
username | Yes | secrets.P6M_ARTIFACTORY_USERNAME |
password | Yes | secrets.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.
| Inputs | None |
| Outputs | name - the buildx instance name, consumed by step 9 as builder-namedriver - the buildx driver in useendpoint - 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.
| Input | Value in the generated workflow | Notes |
|---|---|---|
dockerfile-path | .platform/docker/prd/Dockerfile | The 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-tag | steps.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/applications | Full Artifactory Docker repository path |
platforms | linux/amd64,linux/arm64 | See Multi-Arch Builds |
push | ${{ github.ref_name == 'main' }} | Build-only on branches, build-and-push on main |
skip-setup | true | Buildx 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 |
| Output | Description |
|---|---|
image-digest | The immutable sha256:... content digest. This is the value the entire CD path keys on |
image-metadata | The raw build result metadata JSON from buildx |
image-uri | The full tagged URI, <registry>/<image-name>:<image-tag> |
image-digest is empty when push is falsedocker/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
| Inputs | steps.docker-publish.outputs.image-digest |
| Outputs | digest.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.
| Input | Value | Effect |
|---|---|---|
name | Version <version> | Release title |
tag | steps.cut-patch.outputs.tag | The tag created in step 5 |
makeLatest | true | Marks this release as "Latest" |
artifacts | digest.txt | Uploads the digest file produced in step 10 |
removeArtifacts | true | Clears previously attached assets before uploading |
generateReleaseNotes | true | GitHub generates the commit/PR changelog |
body | version + digest | Prepended 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.
| Input | Required | Value | Purpose |
|---|---|---|---|
repository | Yes | ${{ github.repository }} | owner/repo of the application; becomes the remote Kustomize base |
image-name | Yes | ${{ env.IMAGE_NAME }} | Image name the platform side rewrites |
directory-name | Yes | ${{ env.APPLICATION_NAME }} | Target path kubernetes/<directory-name>/ in the platform repo |
environment | Yes | "dev" | Environment subdirectory. Hardcoded to dev in the generated workflow |
digest | Yes | step 9's image-digest | The digest to pin |
update-manifest-token | Yes | secrets.P6M_UPDATE_MANIFEST_TOKEN | Authorizes the dispatch |
platform-dispatch-url | Yes | vars.P6M_PLATFORM_DISPATCH_URL | The platform repo's dispatch endpoint |
registry | No | (default default) | default for Artifactory, ecr for AWS ECR |
resource-directory-name | No | (default empty) | Points the Kustomize base at a different repo directory when set |
| Output | Description |
|---|---|
status | success, 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 }}
| Trigger | workflow_dispatch only - run it from the Actions tab |
| Input | version-level: patch, minor, or major |
| Outputs | A 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:
- Requests an OIDC token from GitHub Actions (this is what
permissions: id-token: writeis for). - Exchanges it at
https://auth.p6m.dev/api/github/actions/token-exchangefor a P6M GitHub App installation token. - Sets
GITHUB_TOKEN/GH_TOKENand configures git to push asp6m-ybor[bot]. - Installs a
prepare-commit-msggit 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"
| Inputs | The client_payload from the dispatch: repository, directory_name, image_name, environment_dir, digest, registry, resource_directory_name |
| Outputs | Delegates entirely to the reusable workflow below |
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>
| Input | Source | Effect |
|---|---|---|
client_payload.directory_name | dispatch | Normalized, becomes kubernetes/<name>/ |
client_payload.environment_dir | dispatch | Environment subdirectory, and the remote base's environment overlay |
client_payload.repository | dispatch | The remote Kustomize base, unless resource_directory_name overrides it |
client_payload.image_name | dispatch | Image to rewrite; falls back to the normalized directory name when empty |
client_payload.digest | dispatch | Pinned with @sha256:... |
client_payload.registry | dispatch | default builds an Artifactory path, ecr builds an ECR path, anything else is a no-op |
vars.P6M_ARTIFACTORY_HOSTNAME | platform repo | Registry hostname in the rewritten image reference |
| Output | Description |
|---|---|
kubernetes/<app>/<env>/kustomization.yaml | The updated, digest-pinned overlay |
Two meta.p6m.dev annotations | Source repository and last-updated timestamp, for traceability |
<image> and <image>-server are rewrittenThe 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>"
| Inputs | The staged kustomization.yaml, plus PLATFORM_TOKEN_EXCHANGE_URL and PLATFORM_INSTALLATION_ID for App-token authentication |
| Outputs | A 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.
| Inputs | The platform repo commit; the application repo's .platform/kubernetes/<env> overlay |
| Outputs | A 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.
| .NET | Go | Java | Python | Rust | TypeScript | |
|---|---|---|---|---|---|---|
| Setup action | dotnet-setup | golang-setup | java-maven-setup | python-uv-setup | rust-setup | js-pnpm-setup |
| Build action | dotnet-build | golang-build | java-maven-build | python-uv-build | rust-build | js-pnpm-build |
| Cut-tag action | dotnet-cut-tag | golang-cut-tag | java-maven-cut-tag | python-uv-cut-tag | rust-cut-tag | js-pnpm-cut-tag |
| Version lives in | Directory.Build.props | git tags only | pom.xml | pyproject.toml | Cargo.toml | package.json |
| Extra step | - | - | - | Package repository login | apt-get install protobuf-compiler | - |
fetch-depth: 0 in build | No | Yes | No | No | No | No |
| Build-step overrides | run-tests, publish-artifacts | none | run-test, build-command | none | none | none |
| Version-bump commit | Yes | No | Yes | Yes | Yes | Yes |
Uses token-exchange | Yes | No | Yes | Yes | Yes | No (p6m-release-action) |
Per-Language Detail
And for retrofitting the platform layer onto an application that already exists:
- Retrofit Overlays - the
*-service-empty-archetypetier
Required Secrets and Variables
The generated workflows reference these directly. All are provisioned by the platform during onboarding.
| Name | Kind | Used by |
|---|---|---|
P6M_ARTIFACTORY_HOSTNAME | Variable | Registry login, image registry path |
P6M_ARTIFACTORY_PROJECT | Variable | Image registry path |
P6M_PLATFORM_DISPATCH_URL | Variable | Manifest dispatch |
P6M_ARTIFACTORY_USERNAME | Secret | Registry login, Python package login |
P6M_ARTIFACTORY_IDENTITY_TOKEN | Secret | Registry login, Python package login |
P6M_UPDATE_MANIFEST_TOKEN | Secret | Manifest dispatch |
See Platform Secrets & Variables if your organization still uses the older unprefixed names.
Related
- CI/CD Overview - the action catalog and how to compose your own pipeline
- Versioning - the semantic versioning model behind the cut-tag actions
- Promotion - adding
stgandprdpromotion to a generated service - Containerization - the Dockerfiles these workflows build
- Multi-Arch Builds - why
linux/amd64,linux/arm64 - Language Reference - the full p6m-actions catalog per language