Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/workflows/deploy-preview.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Deploy Preview for deploy-platform service repos.
#
# Any PR touching platform/ gets a full render + validation and a sticky
# "Deploy Preview" comment with the SC-11 readiness scorecard.
name: Deploy Preview

on:
pull_request:
paths:
- 'platform/**'

permissions: {}

jobs:
deploy-preview:
uses: JorisJonkers-dev/github-workflows/.github/workflows/deploy-validate.yml@4b444f9a50edf76e4aea4148d73407c34a1e43a4 # v0.12.1
with:
deploy-dir: platform
schema-version: 0.16.0
image-lock-path: platform/images.lock.json
context-ref: ghcr.io/jorisjonkers-dev/cluster-deploy-context-public@sha256:0423d9bcfc091f3b6e9bd6bdd91128bb0278b97e9c9de1907e6b0817c5500232
environments: production
comment: true
permissions:
contents: read
packages: read
pull-requests: write
240 changes: 240 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
# Tag-triggered publish flow for agents-api.
#
# push of a v*.*.* tag drives:
# publish-image -> resolve-image-lock -> publish-deploy-artifact -> register-service
#
# Boundary rule (R1-2): the image lock crosses the reusable-workflow boundary
# as an UPLOADED ARTIFACT (image-lock-artifact), never as a runner-local path.
name: Publish

on:
push:
tags:
- 'v*.*.*'
workflow_dispatch:
inputs:
tag:
description: 'Tag to publish (e.g. v0.3.0)'
required: true
type: string

permissions: {}

jobs:
# -- 1. Build and push the container image --------------------------------
publish-image:
uses: JorisJonkers-dev/github-workflows/.github/workflows/container-publish.yml@4b444f9a50edf76e4aea4148d73407c34a1e43a4 # v0.12.1
with:
image-name: agents-api/agents-api
version: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
permissions:
contents: read
packages: write

# -- 2. Resolve the image lock (upload artifact, never path) --------------
resolve-image-lock:
needs: [publish-image]
runs-on: ubuntu-latest
permissions:
contents: read
packages: read
outputs:
images-lock-artifact: images-lock-${{ github.run_id }}
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }}

- name: resolve-image-digest
id: digest
env:
GH_TOKEN: ${{ github.token }}
IMAGE: ghcr.io/jorisjonkers-dev/agents-api/agents-api
TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
run: |
set -euo pipefail
printf '%s' "$GH_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin
digest="$(docker buildx imagetools inspect "${IMAGE}:${TAG}" --format '{{json .Manifest}}' | jq -r '.digest')"
if [ -z "$digest" ] || [ "$digest" = "null" ]; then
echo "E_FLOATING_IMAGE: could not resolve a digest for ${IMAGE}:${TAG}" >&2
exit 1
fi
echo "image-digest=$digest" >> "$GITHUB_OUTPUT"

- name: lock-image
env:
IMAGE_DIGEST: ${{ steps.digest.outputs.image-digest }}
run: |
set -euo pipefail
if [ -z "$IMAGE_DIGEST" ] || [ "$IMAGE_DIGEST" = "null" ]; then
echo "E_FLOATING_IMAGE: IMAGE_DIGEST is empty" >&2
exit 1
fi
case "$IMAGE_DIGEST" in
sha256:*) ;;
*) echo "E_FLOATING_IMAGE: IMAGE_DIGEST is not a digest ref (got: $IMAGE_DIGEST)" >&2; exit 1 ;;
esac
printf '{"agents-api":"ghcr.io/jorisjonkers-dev/agents-api/agents-api@%s"}\n' "$IMAGE_DIGEST" \
> platform/images.lock.json
echo "Locked image: ghcr.io/jorisjonkers-dev/agents-api/agents-api@${IMAGE_DIGEST}"

- name: verify-lock
run: |
set -euo pipefail
lock_ref=$(jq -r '.["agents-api"]' platform/images.lock.json)
case "$lock_ref" in
*@sha256:*) echo "Digest pin verified: $lock_ref" ;;
*) echo "E_FLOATING_IMAGE: lock ref is not digest-pinned: $lock_ref" >&2; exit 1 ;;
esac

- name: upload-image-lock
uses: actions/upload-artifact@v5
with:
name: images-lock-${{ github.run_id }}
path: platform/images.lock.json
retention-days: 1

# -- 3. Render and publish the deploy artifact ----------------------------
publish-deploy-artifact:
needs: [resolve-image-lock]
uses: JorisJonkers-dev/github-workflows/.github/workflows/deploy-artifact.yml@4b444f9a50edf76e4aea4148d73407c34a1e43a4 # v0.12.1
with:
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }}
artifact-name: agents-api
artifact-version: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
schema-version: 0.16.0
context-ref: ghcr.io/jorisjonkers-dev/cluster-deploy-context-public@sha256:0423d9bcfc091f3b6e9bd6bdd91128bb0278b97e9c9de1907e6b0817c5500232
deploy-dir: platform
image-lock-artifact: ${{ needs.resolve-image-lock.outputs.images-lock-artifact }}
environments: production
permissions:
contents: read
packages: write
id-token: write
attestations: write

# -- 4. Open/refresh the homelab-deploy registry PR ------------------------
register-service:
needs: [publish-deploy-artifact]
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: mint-app-token
id: app-token
uses: actions/create-github-app-token@v3
with:
app-id: ${{ secrets.RELEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: homelab-deploy

- name: assert-token-minted
env:
APP_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
if [ -z "$APP_TOKEN" ]; then
echo "E_APP_TOKEN_MISSING: App token was not minted for register-service." >&2
exit 1
fi

- uses: actions/checkout@v7
with:
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }}

- name: build-registry-payload
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
ARTIFACT_REF: ${{ needs.publish-deploy-artifact.outputs.artifact-ref }}
ARTIFACT_DIGEST: ${{ needs.publish-deploy-artifact.outputs.artifact-digest }}
SERVICE_NAME: agents-api
REPO_OWNER: ${{ github.repository_owner }}
SOURCE_REPOSITORY: ${{ github.repository }}
TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
run: |
set -euo pipefail
branch="registry-update/${SERVICE_NAME}/${TAG}"
registry_path="registry/${SERVICE_NAME}.yaml"
repo="${REPO_OWNER}/homelab-deploy"

if ! gh api "repos/${repo}/git/refs/heads/${branch}" >/dev/null 2>&1; then
main_sha="$(gh api "repos/${repo}/git/refs/heads/main" --jq '.object.sha')"
gh api --method POST "repos/${repo}/git/refs" \
-f ref="refs/heads/${branch}" -f sha="$main_sha" >/dev/null
fi

current_sha=""
if gh api "repos/${repo}/contents/${registry_path}?ref=${branch}" > current.json 2>/dev/null; then
current_sha="$(jq -r '.sha' current.json)"
jq -r '.content' current.json | base64 -d > current.yaml
fi

python3 - "$SERVICE_NAME" <<'PY'
import os, sys, yaml

service = sys.argv[1]
artifact = {
"ref": os.environ["ARTIFACT_REF"],
"digest": os.environ["ARTIFACT_DIGEST"],
"contractPath": "artifact-contract.yaml",
}

if os.path.exists("current.yaml"):
doc = yaml.safe_load(open("current.yaml"))
doc["spec"]["artifact"] = artifact
else:
dep = yaml.safe_load(open("platform/deployment.yml"))
spec = dep.get("spec", {})
platform = spec.get("platform") or {}
layer = platform.get("layer")
if not layer:
sys.exit("E_UNKNOWN_LAYER: deployment.yml spec.platform.layer is "
"required for first registration")
workloads = spec.get("workloads") or []
health = (workloads[0].get("health") if workloads else None) or {}
doc = {
"apiVersion": "deployment.jorisjonkers.dev/deploy-unit-registration/v1",
"kind": "DeployUnitRegistration",
"metadata": {
"name": service,
"owner": os.environ["REPO_OWNER"],
},
"spec": {
"artifact": artifact,
"namespace": spec["namespace"],
"layer": layer,
"sourceRepository": os.environ["SOURCE_REPOSITORY"],
"environments": spec.get("environments") or ["production"],
"healthClass": health.get("class")
or health.get("timeoutClass")
or "stateless",
"prune": {"default": True},
"allowedClusterScope": [],
},
}

yaml.safe_dump(doc, open("registration.yaml", "w"),
sort_keys=False, default_flow_style=False)
PY

if [ -n "$current_sha" ]; then
gh api --method PUT "repos/${repo}/contents/${registry_path}" \
-f message="chore(registry): update ${SERVICE_NAME} to ${TAG}" \
-f content="$(base64 -w0 registration.yaml)" \
-f sha="$current_sha" -f branch="$branch" >/dev/null
else
gh api --method PUT "repos/${repo}/contents/${registry_path}" \
-f message="chore(registry): register ${SERVICE_NAME}" \
-f content="$(base64 -w0 registration.yaml)" \
-f branch="$branch" >/dev/null
fi

open_prs="$(gh api "repos/${repo}/pulls?head=${REPO_OWNER}:${branch}&state=open" --jq 'length')"
if [ "$open_prs" -eq 0 ]; then
gh api --method POST "repos/${repo}/pulls" \
-f title="chore(registry): update ${SERVICE_NAME} to ${TAG}" \
-f body="Automated registry update from publish.yml. Artifact: ${ARTIFACT_REF}" \
-f head="$branch" -f base="main" >/dev/null
else
echo "Updated existing registry PR for branch ${branch}"
fi
Loading
Loading