Skip to content

feat: chained release-publish-oci workflow for service repos - #93

Merged
sebasnallar merged 2 commits into
mainfrom
feat/release-publish-oci
Aug 26, 2026
Merged

feat: chained release-publish-oci workflow for service repos#93
sebasnallar merged 2 commits into
mainfrom
feat/release-publish-oci

Conversation

@sebasnallar

Copy link
Copy Markdown
Contributor

Summary

Standardizes the release pipeline for service repos that ship an OCI image (scopes-lambda and the many similar repos coming), fixing two field problems at the root:

  1. Publish never triggered on release. release-please creates tags with GITHUB_TOKEN, and GitHub never fires workflows from bot-token events — so on: push: tags publish workflows silently did not run. The manual workaround (delete + re-push the tag) works once but flips the GitHub release to Draft (that is where scopes-lambda's draft v0.3.0/v0.3.1 came from).
  2. Releases carried no artifact metadata — nothing to review or copy for the published image.

Change

  • release.yml: declare workflow_call outputs (release_created, tag_name) so callers can chain jobs in the same run.

  • New release-publish-oci.yml — the standard chain, one workflow run, zero cross-workflow triggers (so the bot-token limitation is structurally irrelevant, no PAT needed):

    release-please(if release_created)docker-build-push-ecrnp artifact createfinalize: append an Artifact section (image, digest, pinned image@digest reference, artifact id) to the release body and force-publish it (draft=false, which also self-repairs any draft orphaned by a tag delete/re-push).

Per-repo adoption is a ~12-line caller (see the header comment in the workflow). Artifact registration is optional per repo: skipped cleanly when the artifact_np_api_key secret / NP_ARTIFACT_NRN var are absent.

Test plan

  • YAML validated.
  • First consumer: nullplatform/scopes-lambda (companion PR) — verify next release-please merge publishes the image, registers the artifact, and the release ends up published with the Artifact section.

🤖 Generated with Claude Code

@null-paorodrigues

Copy link
Copy Markdown
Contributor

Review notes.

CI is red on this branch

Validate Actions syntax fails: actionlint's shellcheck pass reports SC2016 at release-publish-oci.yml:195 — the backticks inside the single-quoted printf format string. Either add # shellcheck disable=SC2016 above the printf, or build the format string in a variable.

Nested reusable workflows referenced by relative path

release-publish-oci.yml calls ./.github/workflows/release.yml and ./.github/workflows/docker-build-push-ecr.yml. These are the only two uses: ./ references in the repo, so there is no precedent here for the case that matters: this workflow is itself invoked from another repository. The docs describe ./ as the form for reusable workflows "in the same repository as the calling workflow" and do not state how it resolves one level down; the nesting example uses the full {owner}/{repo}/.github/workflows/{file}@{ref} form, which is also the form README.md documents for docker-build-push-ecr.yml.

Either switch both to the explicit form, or confirm on the companion run that the relative form resolves against this repo before adopting the workflow elsewhere.

No path to complete a release whose publish step failed

release_created is only true on the run that cuts the release. If publish or finalize-release fails — caller missing id-token: write, ECR error, build failure — re-running the workflow yields release_created=false, both jobs are skipped, and the release stays without an image and without the Artifact section, with no supported way to finish it.

Given that the stated goal is removing a manual recovery step, a workflow_dispatch path or a force_tag input that lets publish+finalize run for an existing tag would cover this.

Related: a called workflow's permissions: block can only narrow the caller's token, never widen it. A caller that omits id-token: write fails at the ECR login, after release-please has already cut the release. The header comment is the only guard today; a preflight job would move that failure ahead of the release.

Image row can point to a tag that was never pushed

docker-build-push-ecr.yml derives the pushed tag with grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+', which drops prerelease suffixes (v1.2.3-beta.1v1.2.3), while finalize-release writes IMAGE:$TAG from the release tag verbatim. For a prerelease the Image row references a tag that does not exist; Digest and Pinned reference stay correct.

#91 fixes the strip, so ordering the two resolves it. A more durable option: have docker-build-push-ecr.yml output the tag it actually pushed and consume that output in finalize-release instead of re-deriving it.

Artifact ID can be dropped silently

OUTPUT=$(np artifact create ... --format json 2>&1)
...
ARTIFACT_ID=$(echo "$OUTPUT" | jq -r '.id // empty' 2>/dev/null || true)

2>&1 folds stderr into the JSON capture, so any log or warning line from the CLI makes the jq parse fail. That failure is discarded by 2>/dev/null || true, ARTIFACT_ID ends up empty, and the release body prints not registered for an artifact that was in fact created. Keeping stderr separate, and distinguishing "not attempted" from "created, id not parsed", would keep the body consistent with what happened.

Registration skip is inferred rather than declared

The step skips when artifact_np_api_key or NP_ARTIFACT_NRN is absent, so a misspelled secret name or an unset variable produces a green run with not registered in the body. An explicit register_artifact input would make it a declared choice; short of that, ::warning:: instead of echo would surface it in the run annotations.

CLI install

curl -s https://cli.nullplatform.com/install.sh | VERSION=alpha-packages sh resolves a moving channel at run time. For a workflow meant to be the standard across service repos, pinning a specific version makes runs reproducible, and curl -fsSL with set -o pipefail makes a fetch failure surface as a fetch failure rather than as a shell syntax error. Is the alpha channel expected to be permanent here, or does np artifact create land in a stable channel at some point?

Smaller items

  • NULLPLATFORM_API_KEY is declared at job level, so it is also in the environment of the release-body step. Scoping it to the registration step is enough.
  • release.yml is still name: tofu-release with release-type: terraform-module defaults while now also serving service repos. The wrapper overrides both, but the name reads as module-specific.
  • also_tag_latest, aws_region and build_args from docker-build-push-ecr.yml are not exposed as inputs.
  • release-type is kebab-case among otherwise snake_case inputs (inherited from release.yml).
  • No README entry or Summary Table row for the new workflow; every other workflow has one.
  • The --paginate + --jq "[...][0]" lookup works because gh does not emit per-page null results — I checked against a repo with several pages and only the matching page produces a document. That is undocumented behavior, though. Trying releases/tags/$TAG first and falling back to the list would be explicit, and would avoid walking every release page on each run.
  • np artifact create's flag set and JSON output shape are what the unchecked test-plan item covers. Worth confirming the --registry / --repository split is the one the API expects, i.e. registry=public.ecr.aws with the alias kept in --repository, rather than the alias staying in the registry.

One note on the current shape: every value in finalize-release comes in through env: rather than being interpolated into the run block, which keeps ${{ }} out of the shell.

sebasnallar and others added 2 commits August 26, 2026 09:30
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 <noreply@anthropic.com>
id integrity, declared registration, preflight, pushed-tag row

- 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/<tag> 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 <noreply@anthropic.com>
@sebasnallar
sebasnallar force-pushed the feat/release-publish-oci branch from 9b4dae3 to 2dc2925 Compare August 26, 2026 12:33
@sebasnallar

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough pass — everything addressed in the latest push (rebased onto main, so #91's tag fix is in the base). Point by point:

CI red / SC2016 — fixed; the backticks are markdown in a printf format, so it's an annotated # shellcheck disable=SC2016 on that line. actionlint now exits 0 locally on both files.

Nested ./ references — kept relative, deliberately, with the rationale now documented in the header: per the reusable-workflows docs the path form runs the called workflow "from the same commit as the calling workflow", and for the nested calls the calling workflow is this file — so ./ pins the inner calls to whatever ref the outer caller pinned. The explicit @main form would float the inner calls and break version pinning of this workflow (someone consuming release-publish-oci.yml@v1.2.0 would still get release.yml@main). Agreed on verifying against reality before wide adoption: the scopes-lambda companion (nullplatform/scopes-lambda#41) is the canary; if the relative form misresolves cross-repo, its first run fails at job setup and we'll know immediately.

No recovery path — added existing_tag input: skips release-please and runs publish+finalize for an already-created tag (also upserts the release if none exists, so it doubles as backfill for pre-pipeline tags). The documented caller now includes the workflow_dispatch wrapper. And the preflight job you suggested: it fails before release-please cuts anything when the caller omits id-token: write — the runner only exposes ACTIONS_ID_TOKEN_REQUEST_URL when the permission is granted, which makes it cheaply detectable up front.

Image row vs pushed tag — took your durable option: post-#91 docker-build-push-ecr.yml already outputs image_tag (the tag actually pushed), and finalize now consumes it instead of re-deriving from the git tag.

Artifact ID dropped silently — fixed exactly as described: 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 when registration fails (if: !cancelled()) so the release never stays draft, while the failed step keeps the job red; the ID row then says registration failed — see run log.

Registration skip inferred — added register_artifact (boolean, default true). When true and the key/NRN wiring is missing, the job fails with a pointed error — a typo'd secret name can no longer produce a green run. Opting out is now a declared register_artifact: false.

CLI installcurl -fsSL + set -o pipefail, and a np_cli_version input (default alpha-packages) so repos can pin. On your question: np artifact create is expected to land in the stable channel; the input is the migration path — flip the default when it does, callers that pinned keep working.

Smaller items — all taken: NULLPLATFORM_API_KEY scoped to the registration step; release.yml display name → release (cosmetic only — callee name: doesn't feed check names, inputs/jobs untouched); aws_region, build_args, also_tag_latest exposed as passthroughs; README summary row + full section added. Release lookup now tries releases/tags/<tag> first and only falls back to the list walk for drafts, dropping the reliance on the undocumented pagination behavior you flagged. Kept release-type kebab-case since it's pass-through to release.yml and renaming would break its existing callers.

registry/repository split — confirmed against reality rather than docs: scopes-lambda's existing publish-artifact job uses exactly this split (--registry public.ecr.aws, alias in --repository) and its two production runs (v0.3.0/v0.3.1 re-tags) registered successfully.

@sebasnallar
sebasnallar merged commit 62f4ed5 into main Aug 26, 2026
2 checks passed
@sebasnallar
sebasnallar deleted the feat/release-publish-oci branch August 26, 2026 12:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants