Skip to content
Merged
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
80 changes: 48 additions & 32 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,13 @@ name: Publish signed channel
# layout: bundle-<digest>/bundle.tar.gz, channel-<name>/statement.json
# (engine internal/rulesource/githubtransport.go)
#
# PREREQUISITES before this can promote successfully (the chicken-and-egg the
# rollout is sequenced around — see the engine's cutover plan):
# 1. RULES_SIGNING_KEY_ED25519 secret set (base64 32-byte seed from
# `rulesctl keygen`), ideally as a *production environment* secret.
# PREREQUISITES before this can promote successfully:
# 1. RULES_SIGNING_KEY_ED25519 secret set on the `production` environment
# (base64 32-byte seed from `rulesctl keygen`).
# 2. The engine at `engine_ref` embeds the matching PUBLIC key in
# internal/rulesign/keyring.json (else the self-verify step fails closed,
# which correctly blocks promotion).
# 3. A GitHub Environment named `production` with required reviewers (gates the
# promote job below).
# internal/rulesign/keyring.json AND carries cmd/rulesctl (the "Guard" step
# below fails early with a clear message otherwise).
# 3. A GitHub Environment named `production` with required reviewers.

on:
workflow_dispatch:
Expand All @@ -47,6 +45,12 @@ env:
# GitHub side needs only the one signing secret; on rotation, bump this in
# lockstep with keyring.json and the engine build.
RULES_SIGNING_KEY_ID: trustabl-rules-2026-06
# Dispatch inputs are routed through env (NOT interpolated into run: shell) to
# prevent script injection (CWE-94): referenced as $CHANNEL / $TTL_HOURS /
# $ENGINE_REF, a malicious value stays inert data instead of becoming shell code.
CHANNEL: ${{ github.event.inputs.channel }}
TTL_HOURS: ${{ github.event.inputs.ttl_hours }}
ENGINE_REF: ${{ github.event.inputs.engine_ref }}

permissions:
contents: write # create/upload releases
Expand All @@ -58,13 +62,20 @@ concurrency:
cancel-in-progress: false

jobs:
# Build the candidate bundle + statement and prove the engine can verify them
# against the live releases. No channel re-point happens here.
# Build the candidate bundle + statement and prove the engine can verify them.
# No channel re-point happens here.
build:
runs-on: ubuntu-latest
outputs:
digest: ${{ steps.bundle.outputs.digest }}
steps:
# Reject malformed inputs up front. Defense in depth on top of the env
# routing above: $CHANNEL is also used as a release-tag/path component.
- name: Validate inputs
run: |
[[ "$CHANNEL" =~ ^[a-z0-9._-]+$ ]] || { echo "invalid channel: '$CHANNEL'"; exit 1; }
[[ "$TTL_HOURS" =~ ^[0-9]+$ ]] || { echo "invalid ttl_hours: '$TTL_HOURS'"; exit 1; }

- name: Checkout rules
uses: actions/checkout@v4
with:
Expand All @@ -74,7 +85,7 @@ jobs:
uses: actions/checkout@v4
with:
repository: trustabl/trustabl
ref: ${{ inputs.engine_ref }}
ref: ${{ github.event.inputs.engine_ref }}
path: engine

- uses: actions/setup-go@v5
Expand All @@ -83,6 +94,16 @@ jobs:
cache: true
cache-dependency-path: engine/go.sum

# Fail early, with a clear message, if the engine ref lacks the signing
# tooling or embeds an empty keyring (e.g. dispatched before the engine
# change merged) — instead of a raw build/verify error later.
- name: Guard - engine carries signing tooling + key
run: |
test -f engine/cmd/rulesctl/main.go || {
echo "engine ref '$ENGINE_REF' has no cmd/rulesctl - merge the engine change first"; exit 1; }
python3 -c "import json,sys; sys.exit(0 if json.load(open('engine/internal/rulesign/keyring.json')).get('keys') else 1)" || {
echo "engine keyring.json embeds no signing key - this build cannot verify signed channels"; exit 1; }

- name: Build publisher + scanner
working-directory: engine
run: |
Expand Down Expand Up @@ -114,16 +135,16 @@ jobs:
run: |
tag="bundle-${{ steps.bundle.outputs.digest }}"
if gh release view "$tag" -R "${{ github.repository }}" >/dev/null 2>&1; then
echo "bundle release $tag already exists skipping (content-addressed)."
echo "bundle release $tag already exists - skipping (content-addressed)."
elif gh release create "$tag" "$RUNNER_TEMP/bundle.tar.gz#bundle.tar.gz" \
-R "${{ github.repository }}" \
--title "Rules bundle ${{ steps.bundle.outputs.digest }}" \
--notes "Immutable content-addressed rule bundle. Committed by a signed channel statement."; then
echo "created bundle release $tag."
elif gh release view "$tag" -R "${{ github.repository }}" >/dev/null 2>&1; then
# Lost a create race with a concurrent run; the content is identical
# Lost a create race with a concurrent run; content is identical
# (content-addressed), so an existing tag is success, not failure.
echo "bundle release $tag created concurrently ok."
echo "bundle release $tag created concurrently - ok."
else
echo "failed to create bundle release $tag"; exit 1
fi
Expand All @@ -135,21 +156,18 @@ jobs:
RULES_SIGNING_KEY_ED25519: ${{ secrets.RULES_SIGNING_KEY_ED25519 }}
run: |
test -n "$RULES_SIGNING_KEY_ED25519" || { echo "RULES_SIGNING_KEY_ED25519 secret not set"; exit 1; }
ttl="${{ inputs.ttl_hours }}h"
"$RUNNER_TEMP/trustabl" version >/dev/null 2>&1 || true
"$RUNNER_TEMP/rulesctl" sign \
--channel "${{ inputs.channel }}" \
--channel "$CHANNEL" \
--digest "${{ steps.bundle.outputs.digest }}" \
--key-id "$RULES_SIGNING_KEY_ID" \
--ttl "$ttl" \
--ttl "${TTL_HOURS}h" \
--out "$RUNNER_TEMP/statement.json"
cat "$RUNNER_TEMP/statement.json"

# Self-verify the CANDIDATE before promote. This checks the statement we just
# signed — NOT the live channel (which is only re-pointed in the gated
# promote job below) — against the engine's embedded trust keyring: signature,
# channel binding, freshness, and that the statement's digest matches the
# export we bundled. It is offline and needs no published channel release, so
# Self-verify the CANDIDATE before promote: check the statement we just
# signed — NOT the live channel (only re-pointed in the gated promote job) —
# against the engine's embedded trust keyring: signature, channel binding,
# freshness, and that the digest matches the export we bundled. Offline, so
# it works on the very first publish too. Fails closed if the keyring at
# engine_ref is empty/missing, which correctly blocks promotion.
- name: Self-verify the candidate statement
Expand All @@ -158,7 +176,7 @@ jobs:
--statement "$RUNNER_TEMP/statement.json" \
--bundle-dir export \
--keyring engine/internal/rulesign/keyring.json \
--channel "${{ inputs.channel }}"
--channel "$CHANNEL"

- name: Upload statement artifact
uses: actions/upload-artifact@v4
Expand All @@ -182,22 +200,20 @@ jobs:
path: stmt

# NOTE (clobber window): re-pointing the asset has a brief window where a
# concurrent scan could see a 404 (tolerable — engine falls back to cache)
# or, worse, a truncated body (hard ParseStatement failure). Promote
# off-peak; a fully atomic re-point would publish a versioned asset and flip
# a pointer, which GitHub release assets do not natively support.
# concurrent scan could see a 404 (tolerable - engine falls back to cache)
# or a truncated body. Promote off-peak.
- name: Promote channel
run: |
tag="channel-${{ inputs.channel }}"
tag="channel-$CHANNEL"
if gh release view "$tag" -R "${{ github.repository }}" >/dev/null 2>&1; then
gh release upload "$tag" "stmt/statement.json" --clobber -R "${{ github.repository }}"
else
# First publish for this channel. If the create loses a race, fall back
# to clobber-upload so the channel ends up pointed at this statement.
gh release create "$tag" "stmt/statement.json#statement.json" \
-R "${{ github.repository }}" \
--title "Channel: ${{ inputs.channel }}" \
--notes "Signed pointer to the current bundle for the ${{ inputs.channel }} channel." \
--title "Channel: $CHANNEL" \
--notes "Signed pointer to the current bundle for the $CHANNEL channel." \
|| gh release upload "$tag" "stmt/statement.json" --clobber -R "${{ github.repository }}"
fi
echo "promoted ${{ inputs.channel }} -> bundle ${{ needs.build.outputs.digest }}"
echo "promoted $CHANNEL -> bundle ${{ needs.build.outputs.digest }}"
Loading