From 6bab43c2b11d238a08297448b4ddca4a546d3575 Mon Sep 17 00:00:00 2001 From: sebasnallar Date: Tue, 25 Aug 2026 13:25:18 -0300 Subject: [PATCH 1/2] feat: chained release-publish-oci workflow + release.yml outputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit release-please creates tags with GITHUB_TOKEN, and GitHub never fires workflows from bot-token events — so per-repo `on: push: tags` publish workflows silently do not run. The field workaround (delete + re-push the tag) triggers them but flips the GitHub release to Draft, which is how orphaned draft releases appeared in scopes-lambda. Make chaining the standard instead: - release.yml now declares workflow_call outputs (release_created, tag_name) so callers can gate downstream jobs in the same run. - New release-publish-oci.yml: release-please -> docker build+push ECR -> np artifact registration -> release finalize, all in one workflow run. The finalize step appends the artifact block (image, digest, pinned reference, artifact id) to the release body so consumers can copy the exact pinned image, and force-publishes the release (also repairing any draft state). Service repos need only a ~12-line caller on push:main; no PAT or GitHub App token required anywhere. Co-Authored-By: Claude Fable 5 --- .github/workflows/release-publish-oci.yml | 204 ++++++++++++++++++++++ .github/workflows/release.yml | 11 ++ 2 files changed, 215 insertions(+) create mode 100644 .github/workflows/release-publish-oci.yml diff --git a/.github/workflows/release-publish-oci.yml b/.github/workflows/release-publish-oci.yml new file mode 100644 index 0000000..14d3fb4 --- /dev/null +++ b/.github/workflows/release-publish-oci.yml @@ -0,0 +1,204 @@ +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): +# +# release-please ──▶ docker build+push ECR ──▶ np artifact register +# │ +# release finalize (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.) +# +# 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] +# 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 +# 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. + +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' + release-type: + description: 'Release Please release type' + 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 + artifact_visible_to: + description: 'Visibility selector for the registered artifact' + required: false + type: string + default: 'organization=*' + 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 (skip registration when absent)' + 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' + value: ${{ 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: + release: + uses: ./.github/workflows/release.yml + with: + release-type: ${{ inputs.release-type }} + update_readme_versions: ${{ inputs.update_readme_versions }} + + publish: + needs: release + if: ${{ needs.release.outputs.release_created == 'true' }} + 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 }} + 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: ${{ needs.release.outputs.release_created == 'true' }} + runs-on: ubuntu-24.04 + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ needs.release.outputs.tag_name }} + DIGEST: ${{ needs.publish.outputs.image_digest }} + ECR_REGISTRY: ${{ inputs.ecr_registry }} + IMAGE_NAME: ${{ inputs.image_name }} + VISIBLE_TO: ${{ inputs.artifact_visible_to }} + NULLPLATFORM_API_KEY: ${{ secrets.artifact_np_api_key }} + NP_ARTIFACT_NRN: ${{ vars.NP_ARTIFACT_NRN }} + steps: + - name: Register oci_image artifact + id: artifact + run: | + if [ -z "$NULLPLATFORM_API_KEY" ] || [ -z "$NP_ARTIFACT_NRN" ]; then + echo "artifact_np_api_key secret or NP_ARTIFACT_NRN variable not set; skipping artifact registration" + echo "artifact_id=" >> "$GITHUB_OUTPUT" + exit 0 + fi + curl -s https://cli.nullplatform.com/install.sh | VERSION=alpha-packages 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 + + OUTPUT=$(np artifact create \ + --nrn "$NP_ARTIFACT_NRN" \ + --type oci_image \ + --registry "$REG_HOST" \ + --repository "$REPOSITORY" \ + --digest "$DIGEST" \ + --visible-to "$VISIBLE_TO" \ + --format json 2>&1) || { echo "$OUTPUT"; exit 1; } + echo "$OUTPUT" + ARTIFACT_ID=$(echo "$OUTPUT" | jq -r '.id // empty' 2>/dev/null || true) + echo "artifact_id=$ARTIFACT_ID" >> "$GITHUB_OUTPUT" + + - name: Append artifact metadata & publish release + env: + ARTIFACT_ID: ${{ steps.artifact.outputs.artifact_id }} + run: | + IMAGE="$ECR_REGISTRY/$IMAGE_NAME" + + # Drafts are not resolvable via releases/tags/:tag — list and filter. + RELEASE=$(gh api "repos/$GITHUB_REPOSITORY/releases" --paginate \ + --jq "[.[] | select(.tag_name==\"$TAG\")][0]") + if [ -z "$RELEASE" ] || [ "$RELEASE" = "null" ]; then + echo "no release found for tag $TAG"; exit 1 + fi + RELEASE_ID=$(echo "$RELEASE" | jq -r '.id') + BODY=$(echo "$RELEASE" | jq -r '.body // ""') + + # Idempotent: skip the append when this digest is already recorded. + if ! printf '%s' "$BODY" | grep -qF "$DIGEST"; then + SECTION=$(printf '\n\n## Artifact\n\n| | |\n|---|---|\n| Image | `%s` |\n| Digest | `%s` |\n| Pinned reference | `%s` |\n| Artifact ID | `%s` |' \ + "$IMAGE:$TAG" "$DIGEST" "$IMAGE@$DIGEST" "${ARTIFACT_ID:-not registered}") + BODY="${BODY}${SECTION}" + fi + + # draft=false also repairs releases orphaned into draft state by a + # tag delete/re-push. + gh api -X PATCH "repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID" \ + -F draft=false -f body="$BODY" > /dev/null + echo "release $TAG published with artifact metadata" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9a8d5a5..09dfd97 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,6 +13,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 From 2dc2925d48bb991a54464a6476e0e1888989b0cf Mon Sep 17 00:00:00 2001 From: sebasnallar Date: Wed, 26 Aug 2026 09:33:20 -0300 Subject: [PATCH 2/2] =?UTF-8?q?fix(release-publish-oci):=20address=20revie?= =?UTF-8?q?w=20=E2=80=94=20recovery=20path,=20artifact=20id=20integrity,?= =?UTF-8?q?=20declared=20registration,=20preflight,=20pushed-tag=20row?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - existing_tag input: skip release-please and publish+finalize an already-created tag (completes a half-finished release, backfills pre-pipeline tags); finalize upserts the release when none exists. - preflight job fails before release-please cuts anything when the caller omits id-token: write (called workflows can only narrow the caller's token; the runner exposes the OIDC endpoint only when granted, making it detectable up front). - Image row uses docker-build-push-ecr's image_tag output (the tag actually pushed after prefix stripping) instead of re-deriving from the git tag. - Artifact id integrity: stdout-only capture (stderr stays on the run log), a create failure fails the step, and a created-but-unparsed id reports "registered (id unavailable)" with a warning — never a false "not registered". The release-body step still runs on registration failure (release must not stay draft) while the job stays red. - register_artifact input (default true) makes registration a declared choice: missing key/NRN wiring is now an error, not a silent skip. - np_cli_version input + curl -fsSL + pipefail for the CLI install. - Release lookup: direct releases/tags/ first, list fallback for drafts; body PATCHed from file. - Expose aws_region, build_args, also_tag_latest passthroughs; scope registration env to its step; shellcheck SC2016 annotated (markdown backticks); README summary row + section; release.yml display name tofu-release -> release (cosmetic, nothing else changed). Co-Authored-By: Claude Fable 5 --- .github/workflows/release-publish-oci.yml | 177 +++++++++++++++++----- .github/workflows/release.yml | 5 +- README.md | 60 ++++++++ 3 files changed, 207 insertions(+), 35 deletions(-) diff --git a/.github/workflows/release-publish-oci.yml b/.github/workflows/release-publish-oci.yml index 14d3fb4..0ec79d7 100644 --- a/.github/workflows/release-publish-oci.yml +++ b/.github/workflows/release-publish-oci.yml @@ -3,9 +3,9 @@ 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): # -# release-please ──▶ docker build+push ECR ──▶ np artifact register -# │ -# release finalize (metadata + publish) +# 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 @@ -16,6 +16,11 @@ name: release-publish-oci # 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). @@ -26,6 +31,12 @@ name: release-publish-oci # 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 @@ -35,12 +46,20 @@ name: release-publish-oci # 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: @@ -69,8 +88,23 @@ on: 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' + description: 'Release Please release type (kebab-case inherited from release.yml)' required: false type: string default: 'simple' @@ -79,25 +113,40 @@ on: 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 (skip registration when absent)' + 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' - value: ${{ jobs.release.outputs.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 }} @@ -108,15 +157,34 @@ permissions: 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: release - if: ${{ needs.release.outputs.release_created == 'true' }} + 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 }} @@ -124,34 +192,49 @@ jobs: dockerfile: ${{ inputs.dockerfile }} platforms: ${{ inputs.platforms }} ecr_registry: ${{ inputs.ecr_registry }} - tag: ${{ needs.release.outputs.tag_name }} + 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: ${{ needs.release.outputs.release_created == 'true' }} + if: ${{ !cancelled() && needs.publish.result == 'success' }} runs-on: ubuntu-24.04 env: GH_TOKEN: ${{ github.token }} - TAG: ${{ needs.release.outputs.tag_name }} + 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 }} - VISIBLE_TO: ${{ inputs.artifact_visible_to }} - NULLPLATFORM_API_KEY: ${{ secrets.artifact_np_api_key }} - NP_ARTIFACT_NRN: ${{ vars.NP_ARTIFACT_NRN }} 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: | - if [ -z "$NULLPLATFORM_API_KEY" ] || [ -z "$NP_ARTIFACT_NRN" ]; then - echo "artifact_np_api_key secret or NP_ARTIFACT_NRN variable not set; skipping artifact registration" - echo "artifact_id=" >> "$GITHUB_OUTPUT" - exit 0 + 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 - curl -s https://cli.nullplatform.com/install.sh | VERSION=alpha-packages sh + 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. @@ -163,6 +246,8 @@ jobs: 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 \ @@ -170,35 +255,59 @@ jobs: --repository "$REPOSITORY" \ --digest "$DIGEST" \ --visible-to "$VISIBLE_TO" \ - --format json 2>&1) || { echo "$OUTPUT"; exit 1; } + --format json) echo "$OUTPUT" - ARTIFACT_ID=$(echo "$OUTPUT" | jq -r '.id // empty' 2>/dev/null || true) + 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 - # Drafts are not resolvable via releases/tags/:tag — list and filter. - RELEASE=$(gh api "repos/$GITHUB_REPOSITORY/releases" --paginate \ - --jq "[.[] | select(.tag_name==\"$TAG\")][0]") - if [ -z "$RELEASE" ] || [ "$RELEASE" = "null" ]; then - echo "no release found for tag $TAG"; exit 1 + # 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 - RELEASE_ID=$(echo "$RELEASE" | jq -r '.id') - BODY=$(echo "$RELEASE" | jq -r '.body // ""') + # 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 - SECTION=$(printf '\n\n## Artifact\n\n| | |\n|---|---|\n| Image | `%s` |\n| Digest | `%s` |\n| Pinned reference | `%s` |\n| Artifact ID | `%s` |' \ - "$IMAGE:$TAG" "$DIGEST" "$IMAGE@$DIGEST" "${ARTIFACT_ID:-not registered}") - BODY="${BODY}${SECTION}" + 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" > /dev/null + -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 09dfd97..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: 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