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
313 changes: 313 additions & 0 deletions .github/workflows/release-publish-oci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,313 @@
name: release-publish-oci

# The standard release pipeline for service repos that ship an OCI image
# (scopes-lambda and the family of service repos modeled on it):
#
# preflight ──▶ release-please ──▶ docker build+push ECR ──▶ finalize
# (artifact register +
# release metadata + publish)
#
# Everything runs in ONE workflow run, chained on `release_created`. This is
# deliberate: release-please creates tags/releases with GITHUB_TOKEN, and
# GitHub never triggers workflows from bot-token events — a separate
# `on: push: tags` publish workflow silently does not fire. Chaining removes
# the cross-workflow trigger entirely, so no PAT / GitHub App token is needed.
# (Historical footnote: the manual workaround — delete + re-push the tag —
# does trigger the tag workflow, but deleting a tag flips its GitHub release
# to Draft, which is how orphaned draft releases appear.)
#
# Recovery / backfill: pass `existing_tag` to skip release-please and run
# publish + finalize for a tag that already exists — completes a release whose
# publish failed, or backfills a pre-pipeline tag. Callers typically expose it
# via a small workflow_dispatch wrapper (see below).
#
# The finalize job appends the artifact block (image, digest, artifact id) to
# the release body so consumers can review and copy the exact pinned image,
# and force-publishes the release (repairs any draft state).
#
# Caller (the whole per-repo file):
#
# name: release
# on:
# push:
# branches: [main]
# workflow_dispatch:
# inputs:
# existing_tag:
# description: 'Publish + finalize an existing tag (recovery/backfill)'
# required: true
# type: string
# permissions:
# contents: write
# pull-requests: write
# id-token: write
# jobs:
# release:
# uses: nullplatform/actions-nullplatform/.github/workflows/release-publish-oci.yml@main
# with:
# image_name: scopes/lambda
# existing_tag: ${{ inputs.existing_tag || '' }}
# secrets:
# aws_role_arn: ${{ secrets.AWS_ROLE_ARN_ECR_PUSH }}
# artifact_np_api_key: ${{ secrets.ARTIFACT_NP_API_KEY }}
#
# Artifact registration additionally reads the NP_ARTIFACT_NRN repository (or
# organization) variable — the owner NRN for the registered artifact.
#
# Nested `uses: ./.github/workflows/...` below resolves against THIS repo at
# the ref the caller pinned: per GitHub's reusable-workflows docs, the path
# form runs the called workflow "from the same commit as the calling
# workflow" — and for these nested calls the calling workflow is this file.
# The explicit {owner}/{repo}@{ref} form would instead float the inner calls
# to whatever ref is named there, breaking version pinning of this workflow.

on:
workflow_call:
inputs:
image_name:
description: 'Image name under the registry (e.g. scopes/lambda)'
required: true
type: string
context:
description: 'Docker build context'
required: false
type: string
default: '.'
dockerfile:
description: 'Dockerfile path relative to context'
required: false
type: string
default: 'Dockerfile'
platforms:
description: 'Target platforms for the multi-arch build'
required: false
type: string
default: 'linux/amd64,linux/arm64'
ecr_registry:
description: 'ECR registry URL prefix'
required: false
type: string
default: 'public.ecr.aws/nullplatform'
aws_region:
description: 'AWS region for ECR'
required: false
type: string
default: 'us-east-1'
build_args:
description: 'Docker build arguments (newline-separated)'
required: false
type: string
default: ''
also_tag_latest:
description: 'Also tag and push the image as latest'
required: false
type: boolean
default: false
release-type:
description: 'Release Please release type (kebab-case inherited from release.yml)'
required: false
type: string
default: 'simple'
update_readme_versions:
description: 'Update ref=vX.Y.Z references in READMEs after release'
required: false
type: boolean
default: false
existing_tag:
description: 'Skip release-please and publish + finalize this existing tag (recovery/backfill)'
required: false
type: string
default: ''
register_artifact:
description: 'Register the image as a nullplatform oci_image artifact (requires artifact_np_api_key + NP_ARTIFACT_NRN)'
required: false
type: boolean
default: true
artifact_visible_to:
description: 'Visibility selector for the registered artifact'
required: false
type: string
default: 'organization=*'
np_cli_version:
description: 'np CLI version/channel for artifact registration (alpha-packages until artifact create reaches stable)'
required: false
type: string
default: 'alpha-packages'
secrets:
aws_role_arn:
description: 'AWS IAM Role ARN for OIDC auth against ECR'
required: true
artifact_np_api_key:
description: 'nullplatform API key for artifact registration (required while register_artifact is true)'
required: false
outputs:
release_created:
description: 'true when a release was cut on this run'
value: ${{ jobs.release.outputs.release_created }}
tag_name:
description: 'Tag of the created release (or existing_tag in recovery mode)'
value: ${{ inputs.existing_tag || jobs.release.outputs.tag_name }}
image_digest:
description: 'OCI image-index digest of the published image'
value: ${{ jobs.publish.outputs.image_digest }}

permissions:
contents: write
pull-requests: write
id-token: write

jobs:
# Fails BEFORE release-please cuts anything when the caller forgot
# `id-token: write`: a called workflow can only narrow the caller's token,
# so without it the ECR OIDC login dies after the release already exists.
# The runner only exposes the token-exchange endpoint when the permission
# is granted, which makes it cheaply detectable.
preflight:
name: Preflight
runs-on: ubuntu-24.04
steps:
- name: Verify caller grants id-token permission
run: |
if [ -z "$ACTIONS_ID_TOKEN_REQUEST_URL" ]; then
echo "::error::the calling workflow must grant 'id-token: write' (required for the ECR push). Add it to the caller's permissions block."
exit 1
fi
echo "id-token permission present"

release:
needs: preflight
if: ${{ inputs.existing_tag == '' }}
uses: ./.github/workflows/release.yml
with:
release-type: ${{ inputs.release-type }}
update_readme_versions: ${{ inputs.update_readme_versions }}

publish:
needs: [preflight, release]
if: ${{ !cancelled() && needs.preflight.result == 'success' && (needs.release.outputs.release_created == 'true' || inputs.existing_tag != '') }}
uses: ./.github/workflows/docker-build-push-ecr.yml
with:
image_name: ${{ inputs.image_name }}
context: ${{ inputs.context }}
dockerfile: ${{ inputs.dockerfile }}
platforms: ${{ inputs.platforms }}
ecr_registry: ${{ inputs.ecr_registry }}
aws_region: ${{ inputs.aws_region }}
build_args: ${{ inputs.build_args }}
also_tag_latest: ${{ inputs.also_tag_latest }}
tag: ${{ inputs.existing_tag || needs.release.outputs.tag_name }}
secrets:
aws_role_arn: ${{ secrets.aws_role_arn }}

finalize-release:
name: Register artifact & finalize release
needs: [release, publish]
if: ${{ !cancelled() && needs.publish.result == 'success' }}
runs-on: ubuntu-24.04
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ inputs.existing_tag || needs.release.outputs.tag_name }}
# The tag docker-build-push-ecr ACTUALLY pushed (it strips monorepo
# prefixes) — the Image row must reference this, not the git tag.
IMAGE_TAG: ${{ needs.publish.outputs.image_tag }}
DIGEST: ${{ needs.publish.outputs.image_digest }}
ECR_REGISTRY: ${{ inputs.ecr_registry }}
IMAGE_NAME: ${{ inputs.image_name }}
steps:
- name: Register oci_image artifact
id: artifact
if: ${{ inputs.register_artifact }}
env:
NULLPLATFORM_API_KEY: ${{ secrets.artifact_np_api_key }}
NP_ARTIFACT_NRN: ${{ vars.NP_ARTIFACT_NRN }}
VISIBLE_TO: ${{ inputs.artifact_visible_to }}
NP_CLI_VERSION: ${{ inputs.np_cli_version }}
run: |
set -o pipefail
# register_artifact is a declared choice: missing wiring is an error,
# not a silent skip (a typo'd secret name must not produce a green run).
if [ -z "$NULLPLATFORM_API_KEY" ]; then
echo "::error::register_artifact is true but the artifact_np_api_key secret is empty or not passed"
exit 1
fi
if [ -z "$NP_ARTIFACT_NRN" ]; then
echo "::error::register_artifact is true but the NP_ARTIFACT_NRN variable is not set"
exit 1
fi
curl -fsSL https://cli.nullplatform.com/install.sh | VERSION="$NP_CLI_VERSION" sh

# np artifact create expects host-only --registry; the repository is
# the registry path plus the image name.
REG_HOST="${ECR_REGISTRY%%/*}"
REG_PATH="${ECR_REGISTRY#*/}"
if [ "$REG_PATH" = "$ECR_REGISTRY" ]; then
REPOSITORY="$IMAGE_NAME"
else
REPOSITORY="$REG_PATH/$IMAGE_NAME"
fi

# stdout only into the capture: stderr stays on the run log, so CLI
# warnings can never corrupt the JSON parse.
OUTPUT=$(np artifact create \
--nrn "$NP_ARTIFACT_NRN" \
--type oci_image \
--registry "$REG_HOST" \
--repository "$REPOSITORY" \
--digest "$DIGEST" \
--visible-to "$VISIBLE_TO" \
--format json)
echo "$OUTPUT"
ARTIFACT_ID=$(echo "$OUTPUT" | jq -r '.id // empty' || true)
if [ -z "$ARTIFACT_ID" ]; then
# Created (the command succeeded) but the id was not in the output:
# keep the run green and say so, never claim "not registered".
echo "::warning::artifact created but no id found in CLI output; release will say 'registered (id unavailable)'"
ARTIFACT_ID="registered (id unavailable)"
fi
echo "artifact_id=$ARTIFACT_ID" >> "$GITHUB_OUTPUT"

- name: Append artifact metadata & publish release
# Runs even when registration failed: the release must still get its
# metadata and leave draft state; the failed step keeps the job red.
if: ${{ !cancelled() }}
env:
ARTIFACT_ID: ${{ steps.artifact.outputs.artifact_id }}
REGISTER_RESULT: ${{ steps.artifact.outcome }}
run: |
IMAGE="$ECR_REGISTRY/$IMAGE_NAME"
case "$REGISTER_RESULT" in
success) ID_ROW="${ARTIFACT_ID}" ;;
skipped) ID_ROW="not registered (register_artifact: false)" ;;
*) ID_ROW="registration failed — see run log" ;;
esac

# Direct lookup first; drafts are not resolvable via releases/tags,
# so fall back to listing (upsert also covers backfilled tags that
# never had a release).
RELEASE_ID=$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$TAG" --jq '.id' 2>/dev/null || true)
if [ -z "$RELEASE_ID" ]; then
RELEASE_ID=$(gh api "repos/$GITHUB_REPOSITORY/releases" --paginate \
--jq "[.[] | select(.tag_name==\"$TAG\")][0].id // empty")
fi

# shellcheck disable=SC2016 # backticks are markdown, not command substitution
SECTION=$(printf '## Artifact\n\n| | |\n|---|---|\n| Image | `%s` |\n| Digest | `%s` |\n| Pinned reference | `%s` |\n| Artifact ID | `%s` |' \
"$IMAGE:$IMAGE_TAG" "$DIGEST" "$IMAGE@$DIGEST" "$ID_ROW")

if [ -z "$RELEASE_ID" ]; then
gh release create "$TAG" --title "$TAG" --notes "$SECTION" --verify-tag
echo "created release $TAG"
exit 0
fi

BODY=$(gh api "repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID" --jq '.body // ""')
# Idempotent: skip the append when this digest is already recorded.
if ! printf '%s' "$BODY" | grep -qF "$DIGEST"; then
BODY=$(printf '%s\n\n%s' "$BODY" "$SECTION")
fi
# draft=false also repairs releases orphaned into draft state by a
# tag delete/re-push.
printf '%s' "$BODY" > body.md
gh api -X PATCH "repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID" \
-F draft=false -F "body=@body.md" > /dev/null
echo "release $TAG published with artifact metadata"
16 changes: 15 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
name: tofu-release
# Historically "tofu-release"; renamed when service repos (via
# release-publish-oci) started consuming it too. Only the display name
# changed — inputs, jobs, and defaults are untouched.
name: release

on:
workflow_call:
Expand All @@ -13,6 +16,17 @@ on:
required: false
type: boolean
default: true
# Exposed so callers can chain publish/register jobs in the SAME workflow
# run. Chaining here is the standard: release-please creates tags with
# GITHUB_TOKEN, and GitHub never triggers workflows from bot-token events,
# so a separate `on: push: tags` publish workflow silently does not fire.
outputs:
release_created:
description: 'true when release-please cut a release on this run'
value: ${{ jobs.release.outputs.release_created }}
tag_name:
description: 'Tag of the created release (e.g. v1.2.3)'
value: ${{ jobs.release.outputs.tag_name }}

permissions:
contents: write
Expand Down
Loading