diff --git a/.github/workflows/release-publish-oci.yml b/.github/workflows/release-publish-oci.yml new file mode 100644 index 0000000..0ec79d7 --- /dev/null +++ b/.github/workflows/release-publish-oci.yml @@ -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" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9a8d5a5..f8d669a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: @@ -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 diff --git a/README.md b/README.md index a106048..c175b24 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ This repository provides reusable GitHub Actions workflows for CI/CD, security s | [tofu-test](#tofu-test) | 🚀 Build & Deploy | Runs OpenTofu test suites for infrastructure modules | | [Changelog and Release](#changelog-and-release) | 📦 Release & Changelog | Automated version bumping and changelog generation | | [tofu-release](#tofu-release) | 📦 Release & Changelog | Creates releases for Terraform modules with version updates | +| [release-publish-oci](#release-publish-oci) | 📦 Release & Changelog | Chained release: release-please, ECR image publish, artifact registration, release metadata | | [tofu-pre-release](#tofu-pre-release) | 📦 Release & Changelog | Previews changelog in pull requests before release | | [readme-ai-generator-v2](#readme-ai-generator-v2) | 📚 Documentation | AI-powered README generation for projects | | [tofu-docs](#tofu-docs) | 📚 Documentation | Generates Terraform module documentation | @@ -389,6 +390,65 @@ Validates that Node.js projects build successfully using pnpm. Only runs the bui +## 📦 Release & Changelog + +### release-publish-oci + +The standard release pipeline for service repos that ship an OCI image. Chains release-please, the ECR image publish, nullplatform artifact registration, and release finalization (artifact metadata appended to the body, release force-published) in a single workflow run — so the GitHub limitation that bot-token events never trigger workflows is structurally irrelevant, and no PAT is needed. Supports `existing_tag` for recovery/backfill of already-created tags. + +**Inputs** + +| Name | Description | Required | Default | +|------|-------------|----------|---------| +| image_name | Image name under the registry (e.g. scopes/lambda) | Yes | - | +| context | Docker build context | No | . | +| dockerfile | Dockerfile path relative to context | No | Dockerfile | +| platforms | Target platforms for the multi-arch build | No | linux/amd64,linux/arm64 | +| ecr_registry | ECR registry URL prefix | No | public.ecr.aws/nullplatform | +| aws_region | AWS region for ECR | No | us-east-1 | +| build_args | Docker build arguments (newline-separated) | No | '' | +| also_tag_latest | Also tag and push the image as latest | No | false | +| release-type | Release Please release type | No | simple | +| update_readme_versions | Update ref=vX.Y.Z references in READMEs after release | No | false | +| existing_tag | Skip release-please; publish + finalize this existing tag | No | '' | +| register_artifact | Register the image as a nullplatform oci_image artifact | No | true | +| artifact_visible_to | Visibility selector for the registered artifact | No | organization=* | +| np_cli_version | np CLI version/channel for artifact registration | No | alpha-packages | + +**Secrets** +- `aws_role_arn` (required): AWS IAM Role ARN for OIDC auth against ECR +- `artifact_np_api_key`: nullplatform API key (required while `register_artifact` is true) + +Also reads the `NP_ARTIFACT_NRN` repository/organization variable (artifact owner NRN), and requires the caller to grant `contents: write`, `pull-requests: write`, and `id-token: write` (a preflight job fails fast when `id-token` is missing). + +**Usage** + +```yaml +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 }} +``` + ## Notes ### AI-Powered Documentation