Skip to content

UID2-7069: harden shared_create_releases composite#233

Merged
jon8787 merged 12 commits into
mainfrom
jn-UID2-7069-harden-shared-create-releases
May 21, 2026
Merged

UID2-7069: harden shared_create_releases composite#233
jon8787 merged 12 commits into
mainfrom
jn-UID2-7069-harden-shared-create-releases

Conversation

@jon8787
Copy link
Copy Markdown
Contributor

@jon8787 jon8787 commented May 20, 2026

Summary

Code-review follow-ups from PR #229 (UID2-6762). Closes UID2-7069.

  • Validate publish_platform at the top of shared_create_releases — a typo no longer silently produces a draft release with an empty body. Implemented as a bash case (case-sensitive); the original contains(fromJSON([...])) expression was case-insensitive and let docker lowercase slip through.
  • One mikepenz step, not five. A Prepare changelog template shell step picks the platform template (bash + jq + placeholder substitution); a single mikepenz/release-changelog-builder-action consumes the output via ${{ steps.changelog_config.outputs.json }}; Create Release.body references the one resulting output. Future template/SHA tweaks happen in one place.
  • NuGet workflow no longer hardcodes UID2.Client as the package id — parsed from <id> in the .nuspec via xmllint (explicitly installed via apt-get install -y libxml2-utils — it is NOT preinstalled on ubuntu-latest), gated on is_release. The .nuspec filename is still hardcoded; that remains as a documented follow-up.
  • Maven workflow: Extract Maven artifactId gated on steps.checkRelease.outputs.is_release (no longer runs on Snapshot builds); same step output used at the composite call site; unused env.IS_RELEASE removed. The inlined is_release boolean at jobs.release.name is documented in-file since job-level expressions cannot reference step outputs.
  • PyPI workflow: Extract PyPI package name gated on is_release and rewritten with python3 tomllib — handles single- and double-quoted values, ignores unrelated name = keys in earlier tables, fails fast if [project].name is missing.

Verification — all run on real GHA Linux (no aspirational checkboxes)

A TEMP smoke harness landed on this branch and was deleted in 32e4920 after capturing three runs. Coverage:

Composite no-op path (is_release: false) — run 26142464874

  • Validate publish_platform rejects docker (lowercase typo) with Unsupported publish_platform 'docker' + exit 1
  • Validate publish_platform accepts each canonical platform: Docker, Maven, PyPI, NuGet, iOS
  • Prepare changelog template bash + jq produces valid configurationJson on real Linux for all 5 platforms (case block extracted live from action.yaml to guarantee parity with the composite)

Extract steps against real consumer fixtures — run 26143046210

  • mvn help:evaluate -Dexpression=project.artifactId on IABTechLab/uid2-attestation-aws/attestation-aws/pom.xmlattestation-aws
  • python3 tomllib on IABTechLab/uid2-client-python/pyproject.tomluid2_client (with underscore — exactly the kind of drift the new parser handles correctly)
  • xmllint --xpath '...local-name()...' on IABTechLab/uid2-client-net/UID2.Client.nuspecUID2.Client

Destructive composite end-to-end (is_release: true) — run 26143611716

  • Composite invoked with publish_platform: Maven, is_release: true, fromTag: v2.3.2 (123 PRs in range so mikepenz takes the template path), toTag: v0.0.0-smoke (temporary tag created at HEAD)
  • Draft releases snapshotted pre-run, restored post-run (defensively — repo had 0 pre-existing drafts; restore was a no-op)
  • Build changelog (mikepenz) successfully consumed ${{ steps.changelog_config.outputs.json }} and substituted #{{CHANGELOG}} / #{{UNCATEGORIZED}} into the Maven template
  • Delete Draft Releases + Create Release ran in sequence; resulting draft body contained <artifactId>smoke-pkg</artifactId> — proving the full plumbing chain (template prep → mikepenz → softprops body interpolation)
  • Cleanup deleted the smoke draft and untagged v0.0.0-smoke

Bugs caught by the smoke and fixed before merge

Commit Bug
fc5ff51 An empty ${{ }} in a code comment inside a run: block. GHA's template engine scans run: blocks for ${{...}} and tried to evaluate the empty expression — composite action YAML failed to load on every invocation. Local yaml.safe_load parsed it fine because YAML itself is valid; only the GHA template pass rejected it.
776f73e Validate publish_platform was using !contains(fromJSON('["Docker",...]'), inputs.publish_platform). GHA's contains() is case-insensitive — so passing docker lowercase returned true, the ! flipped to false, the validation step was silently skipped, and the bad input flowed through to mikepenz. The exact typo the guard was meant to catch. Rewrote as a bash case statement (case-sensitive by default).
4277ce4 NuGet workflow assumed xmllint was preinstalled on ubuntu-latest. It isn't (as of 2026 runner images). Added an explicit sudo apt-get install -y libxml2-utils step gated on is_release. Total overhead ~3-5s per release.

Without the smoke, every one of these would have shipped to production and broken the next real release.

Known limitations (documented in-file, not blocking)

  • The .nuspec filename in the NuGet workflow is still hardcoded as UID2.Client.nuspec. Fix is package-id only; filename remains single-tenant. Follow-up tracked.
  • mikepenz collapses to empty_template (default - no changes) when there are zero merged PRs in the fromTag..toTag range. Pre-existing behavior — not introduced by this PR — but worth a follow-up to set a sensible empty_template that preserves the platform install snippet.

🤖 Generated with Claude Code

jon8787 and others added 12 commits May 20, 2026 14:23
- Validate publish_platform input and fail fast on typos.
- Collapse five Build X Changelog steps into one mikepenz step driven
  by a bash+jq template selector; replace the ternary fallback in
  Create Release.body with a single output reference.
- NuGet workflow: parse package id from <id> in .nuspec instead of
  hardcoding UID2.Client; gate extraction on is_release.
- Maven workflow: gate Extract Maven artifactId on
  steps.checkRelease.outputs.is_release; standardise on that source of
  truth at the composite call site and drop the unused env.IS_RELEASE.
- PyPI workflow: gate Extract PyPI package name on is_release; replace
  grep|cut with python3 tomllib so single-quoted values and non-[project]
  sections appearing earlier in pyproject.toml are handled correctly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Restore the trailing space after "## Image reference to deploy:" in
  the Docker template (regression introduced by the heredoc rewrite —
  caught by a byte-for-byte parity smoke test against the original
  JSON-embedded templates; all five platforms now identical).
- Comment the Maven job.name inline expression explaining why it can't
  reference steps.checkRelease.outputs.is_release (job-level evaluates
  pre-steps) — keep this in sync with the step gates if it ever changes.
- Call out the remaining .nuspec filename hardcode in NuGet workflow as
  a known half-fix; switch <id> extraction from grep|sed to xmllint
  XPath with local-name() so namespace-declared nuspecs work too.
- Note the Python 3.11+ tomllib dependency in the PyPI workflow.
- Rename composite steps for clarity: "Build changelog config" →
  "Prepare changelog template", "Build Changelog" → "Build changelog".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirror the Python 3.11+ note in the PyPI workflow — call out that
xmllint comes from libxml2-utils, preinstalled on ubuntu-latest, and
how a minimal-runner fork would re-add it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Throwaway workflow on the PR branch. Three jobs:
  1. validation guard rejects publish_platform=docker (lowercase) —
     expected to fail; continue-on-error so the workflow itself passes.
  2. validation guard accepts each canonical platform — matrix of 5.
  3. template-prep bash + jq pipeline runs on real Linux for all 5
     platforms; awk-extracts the case block live from action.yaml so
     this stays in sync with the composite.

Deliberately does NOT run the composite with is_release: true —
delete_draft_releases would nuke every existing draft release in the
repo. The standalone template-prep job covers Linux-side rendering
without touching releases.

To be deleted along with this commit-set before merge — pattern
matches the UID2-7041 smoke commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A code comment in the Prepare-changelog-template run block literally
contained "${{ }}" (empty) as descriptive text. GitHub Actions' template
engine scans `run:` blocks for ${{...}} expressions and tried to
evaluate the empty one, failing with:

  action.yaml (Line: 59, Col: 12): An expression was expected
  Failed to load action.yaml

Caught by the smoke harness on the first run (which is exactly what
the harness exists for — local YAML linters parse the file fine
because YAML syntax is valid; only the GHA template pass rejects it).

Rewrite the comment to describe the GHA expression form without
embedding the syntax verbatim.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GitHub Actions' contains() is case-insensitive — so the previous
expression `!contains(fromJSON(["Docker","Maven","PyPI","NuGet","iOS"]),
inputs.publish_platform)` returned false for `publish_platform: docker`,
silently skipping the validate step. The exact typo the ticket asked
this guard to catch.

Caught on the smoke run (rejects-invalid-platform job completed
silently with no error output — composite no-op'd through, fell back
to is_release==false skipping everything else).

Switch to a bash `case` statement on a $PLATFORM env var. Bash case is
case-sensitive by default. The step now runs unconditionally; the case
either matches and exits 0 silently, or hits the wildcard and fails
with the documented error.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add three jobs to the smoke harness that exercise the new Extract bash
logic from each publish workflow against real consumer-repo fixtures,
without actually publishing:

  - extract_maven_artifactid: checks out IABTechLab/uid2-attestation-aws
    and runs `mvn help:evaluate -Dexpression=project.artifactId`. Asserts
    the result is `attestation-aws`.
  - extract_pypi_name: checks out IABTechLab/uid2-client-python and runs
    the new `python3 -c "import tomllib; ..."`. Asserts the result is
    `uid2_client` (with underscore — NOT `uid2-client`, which is exactly
    the kind of drift the in-line comment was missing).
  - extract_nuget_id: checks out IABTechLab/uid2-client-net and runs the
    new `xmllint --xpath` with local-name(). Asserts the result is
    `UID2.Client`.

These complement the existing composite smoke (validation + template
render) by covering the publish-workflow code paths that the composite
smoke can't reach.

Deleted along with the rest of the smoke harness before merge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The earlier comment claimed xmllint was preinstalled on ubuntu-latest
runners. The Path C smoke against uid2-client-net just disproved that:
ubuntu-latest now ships without libxml2-utils, so the Extract NuGet
package id step fails with `xmllint: command not found`.

Add an explicit `sudo apt-get install -y libxml2-utils` step in the
NuGet workflow (gated on is_release, same as the Extract step) and
mirror it in the smoke harness so both code paths match.

Total install overhead: ~3-5s per release run, negligible vs. the full
publish flow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add an opt-in smoke job that exercises the composite with is_release:
true — the only path NOT covered by the existing smoke jobs (which
all use is_release: false to avoid the destructive Delete Draft
Releases side effect).

Design:
  1. workflow_dispatch only (manual trigger), gated on a boolean input
     `run_destructive_e2e` so it never fires accidentally on push.
  2. Pre-step: snapshot every existing draft release (id, name, tag,
     body, prerelease) to a JSON file.
  3. Pre-step: tag HEAD as v0.0.0-smoke so mikepenz has a real toTag
     to resolve. fromTag is the newest existing v-prefixed tag in the
     repo.
  4. Run composite with publish_platform: Maven (single platform —
     the other 4 share identical composite plumbing).
  5. Post-step: assert the smoke draft body contains the Maven-
     specific marker `<artifactId>smoke-pkg</artifactId>`.
  6. if: always() cleanup: delete the smoke draft, recreate every
     snapshotted draft from the JSON file, delete the smoke tag.

Closes the remaining residual gap: GHA $GITHUB_OUTPUT plumbing in a
composite step, mikepenz consuming the new configurationJson string,
softprops interpolating body via steps.changelog.outputs.changelog.

Manual trigger:
  gh workflow run smoke-UID2-7069.yaml \
    --ref jn-UID2-7069-harden-shared-create-releases \
    -f run_destructive_e2e=true

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previous run picked the newest tag (v3.85) as fromTag and HEAD as
toTag. HEAD is the PR branch with direct branch commits (no merged
PRs since v3.85), so mikepenz fell back to its empty_template ("- no
changes") and discarded the platform template entirely — body
verification failed.

Pick the 5th-newest SemVer tag instead. There are guaranteed merged
PRs in range, so mikepenz takes the template path and substitutes
#{{CHANGELOG}} / #{{UNCATEGORIZED}} as intended. Also log the merge
count up-front so future failures are easier to diagnose.

Note: mikepenz collapsing the entire template to "- no changes" on
empty diffs is pre-existing behavior — not a regression in this PR.
A real release ALWAYS has merged PRs between consecutive tags, so
this edge case doesnt surface in production.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
UID2-7069: drop smoke harness; runs captured

Smoke harness served its purpose — caught three real PR bugs (empty
${{ }} in run-block comment, case-insensitive contains() in validation
guard, xmllint not preinstalled on ubuntu-latest) plus verified the
full is_release=true path end-to-end via snapshot+restore against the
real composite.

Runs captured:
  - 26142464874: composite no-op path (5 platforms validation + 5
    template renders on Linux) — green
  - 26143046210: Path C extract steps against real consumer fixtures
    (uid2-attestation-aws, uid2-client-python, uid2-client-net) — green
  - 26143611716: destructive end-to-end (composite with is_release=true,
    publish_platform=Maven, snapshot+restore of draft releases, body
    verification against expected marker) — green

Matches the UID2-7041 cleanup convention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@
Three documentation-only comments added per the latest review pass:

- Placeholder substitution order is silently order-dependent. Note in
  action.yaml that __TAGS__/__IMAGE_TAG__/__REPO__/__VERSION__ are
  reserved tokens and that current inputs come from trusted CI sources.
- Heredoc indentation relies on YAML literal-block-scalar dedent so
  the EOF markers end up flush-left. Spell that out so a future
  contributor doesnt re-indent the heredoc body and break it.
- PyPI extraction assumes PEP 621 layout (project.name); Poetrys
  [tool.poetry] would raise KeyError. No current PyPI consumer uses
  Poetry, so leaving the assumption in place with a note.

Also clarified that the validate-publish_platform step intentionally
runs on Snapshot builds too — caught typos should be loud, not gated
on is_release.

Also fixed an outdated example in the PyPI comment (uid2-client-python
publishes as `uid2_client` with underscore, not `uid2-client` — caught
by the Path C smoke earlier in the PR).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@jon8787 jon8787 merged commit feee335 into main May 21, 2026
3 checks passed
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