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
76 changes: 73 additions & 3 deletions .github/workflows/production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@ name: Production · Build & Deploy
# The dispatch is OFF until the repository variable PRODUCTION_DEPLOY_ENABLED
# is "true" (the build still runs and pushes).
#
# Secrets: AWS_ROLE_ARN, DEVOPS_DISPATCH_TOKEN — the same ones staging.yml uses.
# Secrets: AWS_ROLE_ARN, DEVOPS_DISPATCH_TOKEN, COSIGN_PRIVATE_KEY,
# COSIGN_PASSWORD — the same ones staging.yml uses. The image is signed by
# digest with the Norbix cosign key; without the cosign secrets it is pushed
# unsigned with a warning — and production (image_verification = strict)
# then refuses it.
# Variable: ECR_REGISTRY (optional — falls back to the norbix account registry)

on:
Expand Down Expand Up @@ -67,6 +71,7 @@ jobs:
runs-on: ubuntu-22.04
outputs:
image_ref: ${{ steps.ref.outputs.image_ref }}
digest: ${{ steps.build.outputs.digest }}
steps:
- name: Checkout
uses: actions/checkout@v4
Expand Down Expand Up @@ -95,6 +100,7 @@ jobs:
echo "image_ref=$REGISTRY/norbix/admin:${{ needs.plan.outputs.version }}" >> "$GITHUB_OUTPUT"

- name: Build and push (amd64 + arm64)
id: build
uses: docker/build-push-action@v6
with:
context: .
Expand All @@ -107,6 +113,68 @@ jobs:
cache-from: type=gha,scope=production
cache-to: type=gha,mode=max,scope=production

# ── cosign: sign the pushed image BY DIGEST with the Norbix key ───
# ONE key for every Norbix image (canonical public key: devops
# release/cosign.pub; this repo's copy: security/cosign.pub). Signed by
# IMMUTABLE DIGEST, so the signature covers exactly the bytes just
# pushed, whatever tags point at them. The installs (devops
# terraform/hostinger, self-hosted/terraform) verify it with cosign v3
# and deploy that digest.
# No secrets in this repo → the image is pushed UNSIGNED with a warning
# (nothing breaks before the owner adds them): staging
# (image_verification = warn) still deploys it, production (strict)
# refuses it.
- name: Check signing secrets
id: signing
env:
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
run: |
if [ -n "$COSIGN_PRIVATE_KEY" ] && [ -n "$COSIGN_PASSWORD" ]; then
echo "enabled=true" >> "$GITHUB_OUTPUT"
else
echo "enabled=false" >> "$GITHUB_OUTPUT"
echo "::warning title=Image not signed::Secrets COSIGN_PRIVATE_KEY / COSIGN_PASSWORD are not set in this repo — the image was pushed UNSIGNED. Staging (image_verification = warn) still deploys it; production (strict) refuses it."
fi

# cosign v3.1.3, pinned with its sha256 — the same binary the installs
# verify with (devops self-hosted/terraform/scripts/install-cosign.sh;
# move both together). A direct download instead of
# sigstore/cosign-installer: no floating action tag to break the job.
- name: Install cosign v3.1.3 (sha256 checked)
if: ${{ steps.signing.outputs.enabled == 'true' }}
run: |
set -euo pipefail
curl -fsSL --retry 3 -o /tmp/cosign \
https://github.com/sigstore/cosign/releases/download/v3.1.3/cosign-linux-amd64
echo "4629c757b7618056f8ddd7e2625ae9fdd94c0372a65049520bc7d9df9efc7f71 /tmp/cosign" | sha256sum -c -
sudo install -m 0755 /tmp/cosign /usr/local/bin/cosign
rm -f /tmp/cosign
cosign version | grep GitVersion

- name: Sign image with cosign (by digest) and verify
if: ${{ steps.signing.outputs.enabled == 'true' }}
env:
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
IMAGE_REF: ${{ steps.ref.outputs.image_ref }}
IMAGE_DIGEST: ${{ steps.build.outputs.digest }}
run: |
set -euo pipefail
IMAGE_REF="${IMAGE_REF%:*}" # image_ref carries the tag; sign <repo>@<digest>
[[ "$IMAGE_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]] \
|| { echo "::error::the build step returned no digest ('$IMAGE_DIGEST') — nothing to sign"; exit 1; }
# The key must be THE Norbix key: its public half = security/cosign.pub
# (otherwise every install would reject the signature).
if [ "$(cosign public-key --key env://COSIGN_PRIVATE_KEY | tr -d '[:space:]')" != "$(tr -d '[:space:]' < security/cosign.pub)" ]; then
echo "::error::COSIGN_PRIVATE_KEY is not the Norbix key: its public half differs from security/cosign.pub (devops release/cosign.pub)"
exit 1
fi
cosign sign --yes --key env://COSIGN_PRIVATE_KEY "${IMAGE_REF}@${IMAGE_DIGEST}"
cosign verify --key security/cosign.pub "${IMAGE_REF}@${IMAGE_DIGEST}" > /dev/null
echo "Signed and verified ${IMAGE_REF}@${IMAGE_DIGEST}"
echo "- signed \`${IMAGE_REF}@${IMAGE_DIGEST}\`" >> "$GITHUB_STEP_SUMMARY"

notify-devops:
name: Trigger production deploy
needs: [plan, build-push]
Expand All @@ -117,13 +185,15 @@ jobs:
env:
TOKEN: ${{ secrets.DEVOPS_DISPATCH_TOKEN }}
IMAGE: ${{ needs.build-push.outputs.image_ref }}
DIGEST: ${{ needs.build-push.outputs.digest }}
run: |
# digest = what was pushed and signed; production verifies it (strict)
PAYLOAD=$(jq -n \
--arg i "$IMAGE" \
--arg i "$IMAGE" --arg d "$DIGEST" \
--arg sha "${{ github.sha }}" --arg branch "${{ github.ref_name }}" \
'{ event_type: "production-deploy",
client_payload: { release: "managed", source: "admin", sha: $sha, branch: $branch,
services: [ { name: "admin", image: $i } ] } }')
services: [ { name: "admin", image: $i, digest: $d } ] } }')
echo "$PAYLOAD" | jq .

STATUS=$(curl -s --http1.1 --retry 5 --retry-delay 5 -o /tmp/dispatch-resp.txt -w "%{http_code}" -X POST \
Expand Down
73 changes: 72 additions & 1 deletion .github/workflows/staging.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ name: Staging · Build & Deploy
# different org than gateway/cloud!)
# DEVOPS_DISPATCH_TOKEN — fine-grained PAT for codemash-io/devops,
# Contents: Read and write
# COSIGN_PRIVATE_KEY, — the Norbix cosign key + its password: the
# COSIGN_PASSWORD image is signed by digest (public key
# security/cosign.pub). Optional: without
# them it is pushed unsigned (warning).
# Variable: ECR_REGISTRY (optional — falls back to the norbix account registry)

on:
Expand Down Expand Up @@ -95,6 +99,7 @@ jobs:
runs-on: ubuntu-22.04
outputs:
image_ref: ${{ steps.ref.outputs.image_ref }}
digest: ${{ steps.build.outputs.digest }}
steps:
- name: Checkout
uses: actions/checkout@v4
Expand All @@ -120,6 +125,7 @@ jobs:
echo "image_ref=$REGISTRY/norbix/${{ needs.plan.outputs.image }}:${{ needs.plan.outputs.version }}" >> "$GITHUB_OUTPUT"

- name: Build and push (amd64 — staging box arch)
id: build
uses: docker/build-push-action@v6
with:
context: .
Expand All @@ -133,6 +139,68 @@ jobs:
cache-from: type=gha,scope=staging-${{ needs.plan.outputs.flavor }}
cache-to: type=gha,mode=max,scope=staging-${{ needs.plan.outputs.flavor }}

# ── cosign: sign the pushed image BY DIGEST with the Norbix key ───
# ONE key for every Norbix image (canonical public key: devops
# release/cosign.pub; this repo's copy: security/cosign.pub). Signed by
# IMMUTABLE DIGEST, so the signature covers exactly the bytes just
# pushed, whatever tags point at them. The installs (devops
# terraform/hostinger, self-hosted/terraform) verify it with cosign v3
# and deploy that digest.
# No secrets in this repo → the image is pushed UNSIGNED with a warning
# (nothing breaks before the owner adds them): staging
# (image_verification = warn) still deploys it, production (strict)
# refuses it.
- name: Check signing secrets
id: signing
env:
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
run: |
if [ -n "$COSIGN_PRIVATE_KEY" ] && [ -n "$COSIGN_PASSWORD" ]; then
echo "enabled=true" >> "$GITHUB_OUTPUT"
else
echo "enabled=false" >> "$GITHUB_OUTPUT"
echo "::warning title=Image not signed::Secrets COSIGN_PRIVATE_KEY / COSIGN_PASSWORD are not set in this repo — the image was pushed UNSIGNED. Staging (image_verification = warn) still deploys it; production (strict) refuses it."
fi

# cosign v3.1.3, pinned with its sha256 — the same binary the installs
# verify with (devops self-hosted/terraform/scripts/install-cosign.sh;
# move both together). A direct download instead of
# sigstore/cosign-installer: no floating action tag to break the job.
- name: Install cosign v3.1.3 (sha256 checked)
if: ${{ steps.signing.outputs.enabled == 'true' }}
run: |
set -euo pipefail
curl -fsSL --retry 3 -o /tmp/cosign \
https://github.com/sigstore/cosign/releases/download/v3.1.3/cosign-linux-amd64
echo "4629c757b7618056f8ddd7e2625ae9fdd94c0372a65049520bc7d9df9efc7f71 /tmp/cosign" | sha256sum -c -
sudo install -m 0755 /tmp/cosign /usr/local/bin/cosign
rm -f /tmp/cosign
cosign version | grep GitVersion

- name: Sign image with cosign (by digest) and verify
if: ${{ steps.signing.outputs.enabled == 'true' }}
env:
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
IMAGE_REF: ${{ steps.ref.outputs.image_ref }}
IMAGE_DIGEST: ${{ steps.build.outputs.digest }}
run: |
set -euo pipefail
IMAGE_REF="${IMAGE_REF%:*}" # image_ref carries the tag; sign <repo>@<digest>
[[ "$IMAGE_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]] \
|| { echo "::error::the build step returned no digest ('$IMAGE_DIGEST') — nothing to sign"; exit 1; }
# The key must be THE Norbix key: its public half = security/cosign.pub
# (otherwise every install would reject the signature).
if [ "$(cosign public-key --key env://COSIGN_PRIVATE_KEY | tr -d '[:space:]')" != "$(tr -d '[:space:]' < security/cosign.pub)" ]; then
echo "::error::COSIGN_PRIVATE_KEY is not the Norbix key: its public half differs from security/cosign.pub (devops release/cosign.pub)"
exit 1
fi
cosign sign --yes --key env://COSIGN_PRIVATE_KEY "${IMAGE_REF}@${IMAGE_DIGEST}"
cosign verify --key security/cosign.pub "${IMAGE_REF}@${IMAGE_DIGEST}" > /dev/null
echo "Signed and verified ${IMAGE_REF}@${IMAGE_DIGEST}"
echo "- signed \`${IMAGE_REF}@${IMAGE_DIGEST}\`" >> "$GITHUB_STEP_SUMMARY"

notify-devops:
name: Trigger staging deploy
needs: [plan, build-push]
Expand All @@ -142,13 +210,16 @@ jobs:
env:
TOKEN: ${{ secrets.DEVOPS_DISPATCH_TOKEN }}
run: |
# digest = what was pushed (and signed); devops records it and
# deploys <repo>@<digest>
PAYLOAD=$(jq -n \
--arg r "${{ needs.plan.outputs.release }}" \
--arg i "${{ needs.build-push.outputs.image_ref }}" \
--arg d "${{ needs.build-push.outputs.digest }}" \
--arg sha "${{ github.sha }}" --arg branch "${{ github.ref_name }}" \
'{ event_type: "staging-deploy",
client_payload: { release: $r, source: "admin", sha: $sha, branch: $branch,
services: [ { name: "admin", image: $i } ] } }')
services: [ { name: "admin", image: $i, digest: $d } ] } }')
echo "$PAYLOAD" | jq .

# --http1.1 + retries handle transient network resets (curl 92).
Expand Down
4 changes: 4 additions & 0 deletions security/cosign.pub
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEITuglMoPComC7m+1P3IKqbiyi1n/
vJVHci6DXVV0PXqOjAWhQkraouBNbT26QA1VJYoP4MHSggf/fgeKgSwyjg==
-----END PUBLIC KEY-----
Loading