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
215 changes: 215 additions & 0 deletions .github/workflows/publish-fragment.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
# Publishes one project file's Intent Fragment for one release, and starts
# composition (deploy-kit spec/v1/40-composition.md, spec/v1/55-delivery.md).
#
# release tag -> images built -> this workflow:
# validate -> pack with the release's version -> push -> sign keyless
# -> read back and verify -> dispatch composition in the Estate repository
#
# Call it once per project file, after the release's images exist. A merge that
# is not released publishes nothing: the caller triggers on the tag.
#
# Every decision is a deploy-kit command, at the version the calling
# repository's own lockfile pins. The scripts beside it only hand the command
# its arguments and move bytes.
#
# The signature's identity is this workflow, run for the calling repository, so
# composition verifies every fragment against one subject and reads the
# repository from the certificate.
#
# ONE job: jobs are billed by the minute, rounded up.
name: Publish Fragment

on:
workflow_call:
inputs:
project-file:
description: The project file to publish, relative to the repository root.
required: true
type: string
version:
description: The release, as its tag (vX.Y.Z) or bare (X.Y.Z).
required: true
type: string
ref:
description: The commit the release's images were built from. Defaults to the commit that triggered the caller.
required: false
type: string
default: ""
validate-with:
description: Paths read together with the project file when it is validated, space separated; a directory is read as every file below it.
required: false
type: string
default: ""
migration-proof-artifact:
description: >-
Name of an uploaded artifact holding migration-proof.yml, written by
JorisJonkers-dev/liquibase-runner's migration-proof action earlier in the
caller's run. It lands beside the project file before the fragment is packed.
required: false
type: string
default: ""
toolkit-directory:
description: The directory holding the package.json and package-lock.json that pin @jorisjonkers-dev/deploy-kit.
required: false
type: string
default: "."
node-version:
description: The Node the toolkit runs on.
required: false
type: string
default: "24"
compose:
description: Start composition in the Estate repository once the fragment is published.
required: false
type: boolean
default: true
secrets:
ESTATE_DISPATCH_APP_PRIVATE_KEY:
description: The dispatch App's private key (an organization secret). Required when compose is true.
required: false
outputs:
ref:
description: The published fragment, by digest.
value: ${{ jobs.publish.outputs.ref }}
digest:
description: The published fragment's digest.
value: ${{ jobs.publish.outputs.digest }}
project:
description: The Project the fragment declares.
value: ${{ jobs.publish.outputs.project }}

permissions: {}

concurrency:
# One publish per project file at a time, so `latest` is never raced.
group: publish-fragment-${{ github.repository }}-${{ inputs.project-file }}
cancel-in-progress: false

jobs:
publish:
name: Publish Fragment
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
packages: write
id-token: write
outputs:
ref: ${{ steps.push.outputs.ref }}
digest: ${{ steps.push.outputs.digest }}
project: ${{ steps.pack.outputs.project }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.ref || github.sha }}
persist-credentials: false

# This repository at the release the caller pinned, so the scripts and the
# workflow file are the same version.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: JorisJonkers-dev/github-workflows
ref: v0.18.0 # x-release-please-version
path: .github-workflows
persist-credentials: false

- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: ${{ inputs.node-version }}
registry-url: https://npm.pkg.github.com
scope: "@jorisjonkers-dev"

- name: Install the toolkit at the version this repository pins
working-directory: ${{ inputs.toolkit-directory }}
env:
NODE_AUTH_TOKEN: ${{ github.token }}
TOOLKIT_DIRECTORY: ${{ inputs.toolkit-directory }}
run: |
# The version lives in one place, the caller's lockfile, which Renovate
# moves. Nothing here names a version, and `npx --no-install` below
# can only run what this step installed.
npm ci --ignore-scripts
# Called with nothing to do, the command answers with its usage and
# exits 2. Anything else means the pinned release has no command.
npx --no-install deploy-kit >/dev/null 2>&1 || [ "$?" -eq 2 ] || {
echo "::error::@jorisjonkers-dev/deploy-kit in ${TOOLKIT_DIRECTORY}/package-lock.json ships no deploy-kit command; pin a release that does"
exit 1
}

- name: Fetch the migration proof
if: ${{ inputs.migration-proof-artifact != '' }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ${{ inputs.migration-proof-artifact }}
path: .migration-proof

- name: Put the migration proof beside the project file
if: ${{ inputs.migration-proof-artifact != '' }}
env:
PROJECT_FILE: ${{ inputs.project-file }}
run: |
test -f .migration-proof/migration-proof.yml || {
echo "::error::the artifact holds no migration-proof.yml"
exit 1
}
cp .migration-proof/migration-proof.yml "$(dirname "$PROJECT_FILE")/migration-proof.yml"

- name: Validate and pack
id: pack
env:
PROJECT_FILE: ${{ inputs.project-file }}
VALIDATE_WITH: ${{ inputs.validate-with }}
VERSION: ${{ inputs.version }}
SOURCE_SHA: ${{ inputs.ref || github.sha }}
REPOSITORY: ${{ github.repository }}
TOOLKIT_DIRECTORY: ${{ inputs.toolkit-directory }}
OUT: ${{ runner.temp }}/fragment
run: bash .github-workflows/actions/publish-fragment/pack.sh

- uses: oras-project/setup-oras@005458ad77f1c8facd38a094e4af2e69e5607ff4 # v2.0.2
with:
version: 1.3.4

- uses: sigstore/cosign-installer@7e8b541eb2e61bf99390e1afd4be13a184e9ebc5 # v3.10.1
with:
cosign-release: v2.6.1

- name: Push, sign and read back
id: push
env:
GITHUB_TOKEN: ${{ github.token }}
FRAGMENT: ${{ runner.temp }}/fragment
PROJECT: ${{ steps.pack.outputs.project }}
VERSION: ${{ steps.pack.outputs.version }}
SOURCE_SHA: ${{ inputs.ref || github.sha }}
OWNER: ${{ github.repository_owner }}
SIGNED_REPOSITORY: ${{ github.repository }}
run: |
echo "$GITHUB_TOKEN" | oras login ghcr.io --username "$GITHUB_ACTOR" --password-stdin
echo "$GITHUB_TOKEN" | cosign login ghcr.io --username "$GITHUB_ACTOR" --password-stdin
bash .github-workflows/actions/publish-fragment/push.sh

# The dispatch App can start a run in the Estate repository and cannot
# push to it (deploy-kit spec/v1/60-setup.md#the-estate-repository).
- name: Mint a token for the Estate repository
id: estate
if: ${{ inputs.compose }}
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: ${{ vars.ESTATE_DISPATCH_APP_ID }}
private-key: ${{ secrets.ESTATE_DISPATCH_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: estate

- name: Start composition
if: ${{ inputs.compose }}
env:
GH_TOKEN: ${{ steps.estate.outputs.token }}
ESTATE: ${{ github.repository_owner }}/estate
FRAGMENT_REF: ${{ steps.push.outputs.ref }}
run: |
# Composition pulls every participant's newest fragment itself; the
# dispatch only says that one moved. It is pushed, not polled: a cron
# in this estate runs hours late.
gh workflow run compose.yml --repo "$ESTATE" --ref main
echo "composition started in ${ESTATE} for ${FRAGMENT_REF}"
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ should call released tags instead of branches.
| `actions/api-client-publish` | Generate and publish TypeScript, Java, and Kotlin API clients. |
| `actions/deploy-bundle` | Validate and pack a first-party `deploy/` directory as an OCI bundle. |
| `actions/deploy-sources-render` | Resolve deployment sources, compile Flux output, and emit image tags. |
| `actions/render-diff` | Compose the estate with a pull request's project file and comment the Project's render diff (deploy-kit). |

## Reusable Workflows

Expand All @@ -44,6 +45,7 @@ should call released tags instead of branches.
| `production-canary.yml` | Run caller-owned production smoke checks. |
| `deploy-bundle.yml` | Validate first-party deploy bundles and optionally publish them to GHCR. |
| `deploy-sources-render.yml` | Render deployment sources and expose image tags for downstream tests. |
| `publish-fragment.yml` | Validate, pack, sign and push a project file's Intent Fragment for a release, then start composition (deploy-kit). |
| `repository-hygiene-guard.yml` | Block reintroduction of planning and scratch artifacts. |
| `add-to-project.yml` | Add opened/reopened issues and pull requests to the org Project. |

Expand Down
83 changes: 83 additions & 0 deletions actions/publish-fragment/pack.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
# Validate a project file and pack its Intent Fragment for one release.
#
# Every decision is the deploy-kit command's: this script checks the release
# version's shape, hands the command its arguments, and writes the per-file
# manifest a consumer verifies the pulled package against.
#
# The toolkit is the one the calling repository's lockfile pins. `npm ci` in
# TOOLKIT_DIRECTORY installed it, and `npx --no-install` can run nothing else.
set -euo pipefail

: "${PROJECT_FILE:?}" "${VERSION:?}" "${SOURCE_SHA:?}" "${REPOSITORY:?}" "${OUT:?}"
VALIDATE_WITH="${VALIDATE_WITH:-}"
TOOLKIT_DIRECTORY="${TOOLKIT_DIRECTORY:-.}"
DEPLOY_KIT_COMMAND="${DEPLOY_KIT_COMMAND:-npx --no-install deploy-kit}"

fail() {
echo "publish-fragment: $*" >&2
exit 1
}

workspace="$PWD"
absolute() {
case "$1" in
/*) printf '%s' "$1" ;;
*) printf '%s/%s' "$workspace" "$1" ;;
esac
}

deploy_kit() {
# shellcheck disable=SC2086 # a command and its fixed options, split on purpose
(cd "$TOOLKIT_DIRECTORY" && $DEPLOY_KIT_COMMAND "$@")
}

# A release tag is vX.Y.Z; the fragment carries X.Y.Z.
version="${VERSION#v}"
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail "version '${VERSION}' is not a release, vX.Y.Z"
[[ "$SOURCE_SHA" =~ ^[0-9a-f]{40}$ ]] || fail "source-sha '${SOURCE_SHA}' is not a commit"
[ -f "$PROJECT_FILE" ] || fail "no project file at ${PROJECT_FILE}"

# The project file and what is read with it: env files, Assets. A directory is
# read as every file below it.
read_together=("$(absolute "$PROJECT_FILE")")
# Split on spaces, and never expanded as a pattern.
set -f
for path in $VALIDATE_WITH; do
[ -e "$path" ] || fail "validate-with names ${path}, which does not exist"
read_together+=("$(absolute "$path")")
done
set +f

echo "::group::Validate"
deploy_kit validate "${read_together[@]}"
echo "::endgroup::"

echo "::group::Pack"
out="$(absolute "$OUT")"
rm -rf "$out"
deploy_kit publish "$(absolute "$PROJECT_FILE")" \
--repository "$REPOSITORY" \
--source-sha "$SOURCE_SHA" \
--version "$version" \
--out "$out"
[ -f "$out/fragment.yml" ] || fail "the command packed no fragment.yml"

# Per-file digests, so a consumer can verify the package it pulled.
manifest="$(mktemp)"
(cd "$out" && find . -type f | LC_ALL=C sort | xargs sha256sum) >"$manifest"
mv "$manifest" "$out/MANIFEST.sha256"
echo "::endgroup::"

project="$(yq '.spec.project' "$out/fragment.yml")"
inputs_sha="$(yq '.spec.inputsSha' "$out/fragment.yml")"
[[ "$project" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ ]] || fail "the fragment names project '${project}', which is not a name"

echo "packed ${project} ${version} (${inputs_sha})"
if [ -n "${GITHUB_OUTPUT:-}" ]; then
{
echo "project=${project}"
echo "version=${version}"
echo "inputs-sha=${inputs_sha}"
} >>"$GITHUB_OUTPUT"
fi
80 changes: 80 additions & 0 deletions actions/publish-fragment/push.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
# Push a packed Intent Fragment, sign it keyless, and read it back.
#
# The fragment is pushed under its release version. `latest`, which composition
# resolves, moves only forward: publishing an older release again leaves it
# where it is, so a re-run can never put an earlier fragment in front of
# composition. Going back is a Rollback in the Estate repository, not a push.
set -euo pipefail

: "${FRAGMENT:?}" "${PROJECT:?}" "${VERSION:?}" "${SOURCE_SHA:?}" "${OWNER:?}" "${SIGNED_REPOSITORY:?}"
SIGNER_WORKFLOW="${SIGNER_WORKFLOW:-https://github.com/JorisJonkers-dev/github-workflows/.github/workflows/publish-fragment.yml@}"

fail() {
echo "publish-fragment: $*" >&2
exit 1
}

repository="ghcr.io/$(printf '%s' "$OWNER" | tr '[:upper:]' '[:lower:]')/intent-${PROJECT}"
inputs_sha="$(yq '.spec.inputsSha' "$FRAGMENT/fragment.yml")"

[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail "version '${VERSION}' is not a release"
[[ "$PROJECT" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ ]] || fail "project '${PROJECT}' is not a name"

# The release `latest` names now. Only a registry that says there is no such
# manifest means a first publish. Any other failure to read it stops here: an
# answer that could not be read is not "nothing published yet", and treating
# it so would let a re-run of an old release move `latest` back.
current=""
if manifest="$(oras manifest fetch "${repository}:latest" 2>"${TMPDIR:-/tmp}/latest.err")"; then
current="$(jq -r '.annotations["org.opencontainers.image.version"] // ""' <<<"$manifest")"
[[ "$current" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] ||
fail "${repository}:latest carries no release version, so it cannot be compared with ${VERSION}"
elif ! grep -qiE 'not found|name unknown|manifest unknown' "${TMPDIR:-/tmp}/latest.err"; then
cat "${TMPDIR:-/tmp}/latest.err" >&2
fail "could not read ${repository}:latest"
fi

tags="$VERSION"
if [ -z "$current" ] || [ "$(printf '%s\n%s\n' "$current" "$VERSION" | sort -V | tail -n 1)" = "$VERSION" ]; then
tags="${VERSION},latest"
else
echo "::notice::latest stays at ${current}: ${VERSION} is older, so it is pushed under its own tag only"
fi

# The digest comes from the push itself, never from a tag another run could move.
digest="$(cd "$FRAGMENT" && oras push "${repository}:${tags}" \
--annotation "org.opencontainers.image.version=${VERSION}" \
--annotation "org.opencontainers.image.revision=${SOURCE_SHA}" \
--annotation "dev.jorisjonkers.inputs-sha=${inputs_sha}" \
--format go-template='{{.digest}}' .)"
case "$digest" in
sha256:*) ;;
*) fail "the push returned no digest: '${digest}'" ;;
esac

# Keyless: the identity is this run's OIDC token, so no signing key exists to
# store, rotate or leak.
cosign sign --yes "${repository}@${digest}"

# A push and a signature that succeeded are not evidence a consumer can read
# and trust what was meant. Pull it back by digest, check every file, and
# verify the signature the way composition will: this workflow's identity, run
# for this repository.
check="$(mktemp -d)"
oras pull "${repository}@${digest}" --output "$check" >/dev/null
(cd "$check" && sha256sum -c MANIFEST.sha256 >/dev/null)
cmp "$check/fragment.yml" "$FRAGMENT/fragment.yml"
cosign verify "${repository}@${digest}" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp "^${SIGNER_WORKFLOW//./\\.}" \
--certificate-github-workflow-repository "$SIGNED_REPOSITORY" >/dev/null
rm -rf "$check"

echo "published ${repository}@${digest}"
if [ -n "${GITHUB_OUTPUT:-}" ]; then
{
echo "ref=${repository}@${digest}"
echo "digest=${digest}"
} >>"$GITHUB_OUTPUT"
fi
Loading
Loading