From dbb7379e00076463bed1a4face648962d466976a Mon Sep 17 00:00:00 2001 From: jon8787 <112368577+jon8787@users.noreply.github.com> Date: Wed, 20 May 2026 14:23:10 +1000 Subject: [PATCH 01/12] UID2-7069: harden shared_create_releases composite - 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 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) --- .../shared-publish-to-maven-versioned.yaml | 7 +- .../shared-publish-to-nuget-versioned.yaml | 19 +- .../shared-publish-to-pypi-versioned.yaml | 12 +- actions/shared_create_releases/action.yaml | 188 ++++++++++++------ 4 files changed, 154 insertions(+), 72 deletions(-) diff --git a/.github/workflows/shared-publish-to-maven-versioned.yaml b/.github/workflows/shared-publish-to-maven-versioned.yaml index 7888ae80..54842601 100644 --- a/.github/workflows/shared-publish-to-maven-versioned.yaml +++ b/.github/workflows/shared-publish-to-maven-versioned.yaml @@ -35,7 +35,6 @@ on: default: '' env: - IS_RELEASE: ${{ (inputs.release_type == 'Major' || inputs.release_type == 'Minor' || inputs.release_type == 'Patch') && (github.event.repository.default_branch == github.ref_name ) }} REPO: ${{ github.event.repository.name }} jobs: @@ -161,12 +160,14 @@ jobs: - name: Extract Maven artifactId id: package_name + if: ${{ steps.checkRelease.outputs.is_release == 'true' }} run: | # Maven artifactId comes from pom.xml, not the github repo name # (e.g. uid2-attestation-azure repo publishes the `attestation-azure` # artifact). Use `mvn help:evaluate` as the canonical source so # release-notes install snippets match what `mvn deploy` actually - # published. + # published. Gated on is_release to avoid running on Snapshot builds + # where shared_create_releases is a no-op. artifact_id=$(mvn -B -f "${{ inputs.working_dir }}/pom.xml" help:evaluate -Dexpression=project.artifactId -q -DforceStdout) if [ -z "$artifact_id" ]; then echo "ERROR: could not extract artifactId from ${{ inputs.working_dir }}/pom.xml" >&2 @@ -178,7 +179,7 @@ jobs: - name: Create Release uses: IABTechLab/uid2-shared-actions/actions/shared_create_releases@v3 with: - is_release: ${{ env.IS_RELEASE }} + is_release: ${{ steps.checkRelease.outputs.is_release }} new_version: ${{ steps.version.outputs.new_version }} github_token: ${{ secrets.GITHUB_TOKEN }} publish_platform: Maven diff --git a/.github/workflows/shared-publish-to-nuget-versioned.yaml b/.github/workflows/shared-publish-to-nuget-versioned.yaml index b4a3b64a..e0b5cb85 100644 --- a/.github/workflows/shared-publish-to-nuget-versioned.yaml +++ b/.github/workflows/shared-publish-to-nuget-versioned.yaml @@ -107,6 +107,23 @@ jobs: tag: v${{ steps.version.outputs.new_version }} github_token: ${{ inputs.merge_environment != '' && secrets.GH_MERGE_TOKEN || '' }} + - name: Extract NuGet package id + id: package_name + if: ${{ steps.checkRelease.outputs.is_release == 'true' }} + run: | + # NuGet package id comes from the element in the .nuspec, not + # the github repo name. Parse it dynamically so the shared workflow + # isn't implicitly single-tenant on UID2.Client. Dependencies use + # an `id` attribute (), not an element, + # so this grep matches only the package id. + package_id=$(grep -oE '[^<]+' "${{ inputs.working_dir }}/UID2.Client.nuspec" | head -1 | sed 's/\(.*\)<\/id>/\1/') + if [ -z "$package_id" ]; then + echo "ERROR: could not extract from ${{ inputs.working_dir }}/UID2.Client.nuspec" >&2 + exit 1 + fi + echo "Extracted NuGet package id: $package_id" + echo "name=$package_id" >> "$GITHUB_OUTPUT" + - name: Create Release uses: IABTechLab/uid2-shared-actions/actions/shared_create_releases@v3 with: @@ -114,4 +131,4 @@ jobs: new_version: ${{ steps.version.outputs.new_version }} github_token: ${{ secrets.GITHUB_TOKEN }} publish_platform: NuGet - repo: UID2.Client + repo: ${{ steps.package_name.outputs.name }} diff --git a/.github/workflows/shared-publish-to-pypi-versioned.yaml b/.github/workflows/shared-publish-to-pypi-versioned.yaml index 4aea7a84..9c4e7aeb 100644 --- a/.github/workflows/shared-publish-to-pypi-versioned.yaml +++ b/.github/workflows/shared-publish-to-pypi-versioned.yaml @@ -99,14 +99,18 @@ jobs: - name: Extract PyPI package name id: package_name + if: ${{ steps.checkRelease.outputs.is_release == 'true' }} run: | # PyPI package name comes from pyproject.toml, not the github repo name # (e.g. uid2-client-python repo publishes the `uid2-client` package). - # Read the top-level `name = "..."` field so release-notes install snippets - # match the real PyPI package, not the git repo. - name=$(grep -E '^\s*name\s*=' "${{ inputs.working_dir }}/pyproject.toml" | head -1 | cut -d '"' -f 2) + # Use tomllib (stdlib, Python 3.11+) so we correctly handle both single- + # and double-quoted values, ignore unrelated `name =` keys in earlier + # tables (e.g. dependency tables), and fail fast if [project].name is + # missing. Gated on is_release to avoid running on Snapshot builds + # where shared_create_releases is a no-op. + name=$(python3 -c "import sys, tomllib; print(tomllib.load(open(sys.argv[1], 'rb'))['project']['name'])" "${{ inputs.working_dir }}/pyproject.toml") if [ -z "$name" ]; then - echo "ERROR: could not extract 'name' from ${{ inputs.working_dir }}/pyproject.toml" >&2 + echo "ERROR: could not extract [project].name from ${{ inputs.working_dir }}/pyproject.toml" >&2 exit 1 fi echo "Extracted PyPI package name: $name" diff --git a/actions/shared_create_releases/action.yaml b/actions/shared_create_releases/action.yaml index 592fbe3c..029d459c 100644 --- a/actions/shared_create_releases/action.yaml +++ b/actions/shared_create_releases/action.yaml @@ -31,78 +31,138 @@ runs: using: "composite" steps: - - name: Build Docker Changelog - id: github_release_docker - if: ${{ inputs.is_release == 'true' && inputs.publish_platform == 'Docker' }} - uses: mikepenz/release-changelog-builder-action@32e3c96f29a6532607f638797455e9e98cfc703d # v4 - with: - toTag: v${{ inputs.new_version }} - fromTag: ${{ inputs.from_tag }} - configurationJson: | - { - "template": "#{{CHANGELOG}}\n## Installation\n```\ndocker pull ${{ inputs.tags }}\n```\n\n## Image reference to deploy: \n```\n${{ inputs.image_tag }}\n```\n\n## Changelog\n#{{UNCATEGORIZED}}", - "pr_template": " - #{{TITLE}} - ( PR: ##{{NUMBER}} )" - } - env: - GITHUB_TOKEN: ${{ inputs.github_token }} + # Fail fast on a typo in publish_platform. Without this guard, an unknown + # value (e.g. 'docker' lowercase) silently skips every Build Changelog + # step, lets Delete Draft Releases run, and creates a draft release with + # an empty body. See UID2-7069. + - name: Validate publish_platform + if: ${{ !contains(fromJSON('["Docker","Maven","PyPI","NuGet","iOS"]'), inputs.publish_platform) }} + shell: bash + run: | + echo "::error::Unsupported publish_platform '${{ inputs.publish_platform }}'. Must be one of [Docker, Maven, PyPI, NuGet, iOS]." + exit 1 - - name: Build Maven Changelog - id: github_release_maven - if: ${{ inputs.is_release == 'true' && inputs.publish_platform == 'Maven' }} - uses: mikepenz/release-changelog-builder-action@32e3c96f29a6532607f638797455e9e98cfc703d # v4 - with: - toTag: v${{ inputs.new_version }} - fromTag: ${{ inputs.from_tag }} - configurationJson: | - { - "template": "#{{CHANGELOG}}\n## Maven\n```\n\n com.uid2\n ${{ inputs.repo }}\n ${{ inputs.new_version }}\n\n```\n\n## Jar Files\n- [${{ inputs.repo }}-${{ inputs.new_version }}.jar](https://repo1.maven.org/maven2/com/uid2/${{ inputs.repo }}/${{ inputs.new_version }}/${{ inputs.repo }}-${{ inputs.new_version }}.jar)\n\n## Changelog\n#{{UNCATEGORIZED}}", - "pr_template": " - #{{TITLE}} - ( PR: ##{{NUMBER}} )" - } + # Build the mikepenz configurationJson for the chosen platform. One step + # replaces what used to be five near-identical Build X Changelog steps — + # future changes (mikepenz SHA bump, template tweak) now happen in one + # place. See UID2-7069. + - name: Build changelog config + id: changelog_config + if: ${{ inputs.is_release == 'true' }} + shell: bash env: - GITHUB_TOKEN: ${{ inputs.github_token }} + PLATFORM: ${{ inputs.publish_platform }} + REPO: ${{ inputs.repo }} + VERSION: ${{ inputs.new_version }} + TAGS: ${{ inputs.tags }} + IMAGE_TAG: ${{ inputs.image_tag }} + run: | + # Heredocs use quoted EOF so backticks and $ stay literal — no shell + # expansion inside the template body. We then substitute the four + # placeholders via bash parameter expansion. + case "$PLATFORM" in + Docker) + template=$(cat <<'EOF' + #{{CHANGELOG}} + ## Installation + ``` + docker pull __TAGS__ + ``` - - name: Build PyPI Changelog - id: github_release_pypi - if: ${{ inputs.is_release == 'true' && inputs.publish_platform == 'PyPI' }} - uses: mikepenz/release-changelog-builder-action@32e3c96f29a6532607f638797455e9e98cfc703d # v4 - with: - toTag: v${{ inputs.new_version }} - fromTag: ${{ inputs.from_tag }} - configurationJson: | - { - "template": "#{{CHANGELOG}}\n## PyPI\n```\npip install ${{ inputs.repo }}==${{ inputs.new_version }}\n```\n\n## PyPI page\n- [${{ inputs.repo }} ${{ inputs.new_version }}](https://pypi.org/project/${{ inputs.repo }}/${{ inputs.new_version }}/)\n\n## Changelog\n#{{UNCATEGORIZED}}", - "pr_template": " - #{{TITLE}} - ( PR: ##{{NUMBER}} )" - } - env: - GITHUB_TOKEN: ${{ inputs.github_token }} + ## Image reference to deploy: + ``` + __IMAGE_TAG__ + ``` - - name: Build NuGet Changelog - id: github_release_nuget - if: ${{ inputs.is_release == 'true' && inputs.publish_platform == 'NuGet' }} - uses: mikepenz/release-changelog-builder-action@32e3c96f29a6532607f638797455e9e98cfc703d # v4 - with: - toTag: v${{ inputs.new_version }} - fromTag: ${{ inputs.from_tag }} - configurationJson: | - { - "template": "#{{CHANGELOG}}\n## NuGet\n```\ndotnet add package ${{ inputs.repo }} --version ${{ inputs.new_version }}\n```\n\n## NuGet page\n- [${{ inputs.repo }} ${{ inputs.new_version }}](https://www.nuget.org/packages/${{ inputs.repo }}/${{ inputs.new_version }})\n\n## Changelog\n#{{UNCATEGORIZED}}", - "pr_template": " - #{{TITLE}} - ( PR: ##{{NUMBER}} )" - } - env: - GITHUB_TOKEN: ${{ inputs.github_token }} + ## Changelog + #{{UNCATEGORIZED}} + EOF + ) + ;; + Maven) + template=$(cat <<'EOF' + #{{CHANGELOG}} + ## Maven + ``` + + com.uid2 + __REPO__ + __VERSION__ + + ``` + + ## Jar Files + - [__REPO__-__VERSION__.jar](https://repo1.maven.org/maven2/com/uid2/__REPO__/__VERSION__/__REPO__-__VERSION__.jar) + + ## Changelog + #{{UNCATEGORIZED}} + EOF + ) + ;; + PyPI) + template=$(cat <<'EOF' + #{{CHANGELOG}} + ## PyPI + ``` + pip install __REPO__==__VERSION__ + ``` - - name: Build iOS Changelog - id: github_release_ios - if: ${{ inputs.is_release == 'true' && inputs.publish_platform == 'iOS' }} + ## PyPI page + - [__REPO__ __VERSION__](https://pypi.org/project/__REPO__/__VERSION__/) + + ## Changelog + #{{UNCATEGORIZED}} + EOF + ) + ;; + NuGet) + template=$(cat <<'EOF' + #{{CHANGELOG}} + ## NuGet + ``` + dotnet add package __REPO__ --version __VERSION__ + ``` + + ## NuGet page + - [__REPO__ __VERSION__](https://www.nuget.org/packages/__REPO__/__VERSION__) + + ## Changelog + #{{UNCATEGORIZED}} + EOF + ) + ;; + iOS) + template=$(cat <<'EOF' + #{{CHANGELOG}} + ## Changelog + #{{UNCATEGORIZED}} + EOF + ) + ;; + esac + + template="${template//__TAGS__/$TAGS}" + template="${template//__IMAGE_TAG__/$IMAGE_TAG}" + template="${template//__REPO__/$REPO}" + template="${template//__VERSION__/$VERSION}" + + # Compact JSON (single line) keeps the step output value on one line — + # simpler to pass into mikepenz's configurationJson input via ${{ }}. + config=$(jq -nc \ + --arg t "$template" \ + --arg p ' - #{{TITLE}} - ( PR: ##{{NUMBER}} )' \ + '{template: $t, pr_template: $p}') + + echo "json=$config" >> "$GITHUB_OUTPUT" + + - name: Build Changelog + id: changelog + if: ${{ inputs.is_release == 'true' }} uses: mikepenz/release-changelog-builder-action@32e3c96f29a6532607f638797455e9e98cfc703d # v4 with: toTag: v${{ inputs.new_version }} fromTag: ${{ inputs.from_tag }} - configurationJson: | - { - "template": "#{{CHANGELOG}}\n## Changelog\n#{{UNCATEGORIZED}}", - "pr_template": " - #{{TITLE}} - ( PR: ##{{NUMBER}} )" - } + configurationJson: ${{ steps.changelog_config.outputs.json }} env: GITHUB_TOKEN: ${{ inputs.github_token }} @@ -115,5 +175,5 @@ runs: uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 with: name: v${{ inputs.new_version }} - body: ${{ inputs.publish_platform == 'Docker' && steps.github_release_docker.outputs.changelog || inputs.publish_platform == 'Maven' && steps.github_release_maven.outputs.changelog || inputs.publish_platform == 'PyPI' && steps.github_release_pypi.outputs.changelog || inputs.publish_platform == 'NuGet' && steps.github_release_nuget.outputs.changelog || steps.github_release_ios.outputs.changelog }} + body: ${{ steps.changelog.outputs.changelog }} draft: true From 5d1ee0ae0c56584dd572b87ce616f64ca12e3f0d Mon Sep 17 00:00:00 2001 From: jon8787 <112368577+jon8787@users.noreply.github.com> Date: Wed, 20 May 2026 14:49:34 +1000 Subject: [PATCH 02/12] UID2-7069: address PR #233 review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 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) --- .../shared-publish-to-maven-versioned.yaml | 5 +++++ .../shared-publish-to-nuget-versioned.yaml | 19 +++++++++++++------ .../shared-publish-to-pypi-versioned.yaml | 15 ++++++++++----- actions/shared_create_releases/action.yaml | 6 +++--- 4 files changed, 31 insertions(+), 14 deletions(-) diff --git a/.github/workflows/shared-publish-to-maven-versioned.yaml b/.github/workflows/shared-publish-to-maven-versioned.yaml index 54842601..902237ff 100644 --- a/.github/workflows/shared-publish-to-maven-versioned.yaml +++ b/.github/workflows/shared-publish-to-maven-versioned.yaml @@ -39,6 +39,11 @@ env: jobs: release: + # The release/pre-release boolean is inlined here (rather than referencing + # steps.checkRelease.outputs.is_release) because job-level expressions are + # evaluated before any step runs. Every other "is this a release?" check + # in this file uses steps.checkRelease.outputs.is_release — keep this in + # sync if the condition ever changes. See UID2-7069. name: ${{ ((inputs.release_type == 'Major' || inputs.release_type == 'Minor' || inputs.release_type == 'Patch') && (github.event.repository.default_branch == github.ref_name )) && 'Create Release' || 'Publish Pre-release' }} runs-on: ubuntu-latest environment: ${{ inputs.merge_environment }} diff --git a/.github/workflows/shared-publish-to-nuget-versioned.yaml b/.github/workflows/shared-publish-to-nuget-versioned.yaml index e0b5cb85..20f815e3 100644 --- a/.github/workflows/shared-publish-to-nuget-versioned.yaml +++ b/.github/workflows/shared-publish-to-nuget-versioned.yaml @@ -112,13 +112,20 @@ jobs: if: ${{ steps.checkRelease.outputs.is_release == 'true' }} run: | # NuGet package id comes from the element in the .nuspec, not - # the github repo name. Parse it dynamically so the shared workflow - # isn't implicitly single-tenant on UID2.Client. Dependencies use - # an `id` attribute (), not an element, - # so this grep matches only the package id. - package_id=$(grep -oE '[^<]+' "${{ inputs.working_dir }}/UID2.Client.nuspec" | head -1 | sed 's/\(.*\)<\/id>/\1/') + # the github repo name. Use xmllint's local-name() XPath so we + # ignore the default xmlns the nuspec schema declares. Dependencies + # use an `id` attribute (), not an + # element, so this XPath matches only the package id. + # + # NOTE: the .nuspec file path itself is still hardcoded throughout + # this workflow (UID2.Client.nuspec). Fixing the package id only + # solves half of the single-tenancy problem flagged in UID2-7069 — + # a follow-up should parameterise the .nuspec filename via a + # workflow input. xmllint ships with libxml2-utils on ubuntu-latest. + nuspec="${{ inputs.working_dir }}/UID2.Client.nuspec" + package_id=$(xmllint --xpath 'string(//*[local-name()="package"]/*[local-name()="metadata"]/*[local-name()="id"])' "$nuspec") if [ -z "$package_id" ]; then - echo "ERROR: could not extract from ${{ inputs.working_dir }}/UID2.Client.nuspec" >&2 + echo "ERROR: could not extract from $nuspec" >&2 exit 1 fi echo "Extracted NuGet package id: $package_id" diff --git a/.github/workflows/shared-publish-to-pypi-versioned.yaml b/.github/workflows/shared-publish-to-pypi-versioned.yaml index 9c4e7aeb..77524cc3 100644 --- a/.github/workflows/shared-publish-to-pypi-versioned.yaml +++ b/.github/workflows/shared-publish-to-pypi-versioned.yaml @@ -103,11 +103,16 @@ jobs: run: | # PyPI package name comes from pyproject.toml, not the github repo name # (e.g. uid2-client-python repo publishes the `uid2-client` package). - # Use tomllib (stdlib, Python 3.11+) so we correctly handle both single- - # and double-quoted values, ignore unrelated `name =` keys in earlier - # tables (e.g. dependency tables), and fail fast if [project].name is - # missing. Gated on is_release to avoid running on Snapshot builds - # where shared_create_releases is a no-op. + # Use tomllib so we correctly handle both single- and double-quoted + # values, ignore unrelated `name =` keys in earlier tables (e.g. + # dependency tables), and fail fast if [project].name is missing. + # Gated on is_release to avoid running on Snapshot builds where + # shared_create_releases is a no-op. + # + # REQUIRES Python 3.11+ (tomllib is stdlib from 3.11). ubuntu-latest + # currently ships Python 3.12, so this is safe today; if a caller + # ever forks the workflow onto ubuntu-22.04 (Python 3.10), add an + # actions/setup-python step before this one or fall back to `tomli`. name=$(python3 -c "import sys, tomllib; print(tomllib.load(open(sys.argv[1], 'rb'))['project']['name'])" "${{ inputs.working_dir }}/pyproject.toml") if [ -z "$name" ]; then echo "ERROR: could not extract [project].name from ${{ inputs.working_dir }}/pyproject.toml" >&2 diff --git a/actions/shared_create_releases/action.yaml b/actions/shared_create_releases/action.yaml index 029d459c..4a7f037d 100644 --- a/actions/shared_create_releases/action.yaml +++ b/actions/shared_create_releases/action.yaml @@ -46,7 +46,7 @@ runs: # replaces what used to be five near-identical Build X Changelog steps — # future changes (mikepenz SHA bump, template tweak) now happen in one # place. See UID2-7069. - - name: Build changelog config + - name: Prepare changelog template id: changelog_config if: ${{ inputs.is_release == 'true' }} shell: bash @@ -69,7 +69,7 @@ runs: docker pull __TAGS__ ``` - ## Image reference to deploy: + ## Image reference to deploy: ``` __IMAGE_TAG__ ``` @@ -155,7 +155,7 @@ runs: echo "json=$config" >> "$GITHUB_OUTPUT" - - name: Build Changelog + - name: Build changelog id: changelog if: ${{ inputs.is_release == 'true' }} uses: mikepenz/release-changelog-builder-action@32e3c96f29a6532607f638797455e9e98cfc703d # v4 From 229bbdd593a1b46ee8a4ff29c2f4ca8e205b52e1 Mon Sep 17 00:00:00 2001 From: jon8787 <112368577+jon8787@users.noreply.github.com> Date: Wed, 20 May 2026 14:53:39 +1000 Subject: [PATCH 03/12] UID2-7069: document xmllint runner dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .github/workflows/shared-publish-to-nuget-versioned.yaml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/shared-publish-to-nuget-versioned.yaml b/.github/workflows/shared-publish-to-nuget-versioned.yaml index 20f815e3..91fc5839 100644 --- a/.github/workflows/shared-publish-to-nuget-versioned.yaml +++ b/.github/workflows/shared-publish-to-nuget-versioned.yaml @@ -117,11 +117,16 @@ jobs: # use an `id` attribute (), not an # element, so this XPath matches only the package id. # + # REQUIRES xmllint, provided by libxml2-utils — preinstalled on + # ubuntu-latest. If a caller forks the workflow onto a minimal + # runner, add `sudo apt-get install -y libxml2-utils` before this + # step. + # # NOTE: the .nuspec file path itself is still hardcoded throughout # this workflow (UID2.Client.nuspec). Fixing the package id only # solves half of the single-tenancy problem flagged in UID2-7069 — # a follow-up should parameterise the .nuspec filename via a - # workflow input. xmllint ships with libxml2-utils on ubuntu-latest. + # workflow input. nuspec="${{ inputs.working_dir }}/UID2.Client.nuspec" package_id=$(xmllint --xpath 'string(//*[local-name()="package"]/*[local-name()="metadata"]/*[local-name()="id"])' "$nuspec") if [ -z "$package_id" ]; then From 11609e9f0bcd49ab56d5745e9e02c69796a773d1 Mon Sep 17 00:00:00 2001 From: jon8787 <112368577+jon8787@users.noreply.github.com> Date: Wed, 20 May 2026 14:59:52 +1000 Subject: [PATCH 04/12] UID2-7069: TEMP smoke harness for composite (delete after capture) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .github/workflows/smoke-UID2-7069.yaml | 101 +++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 .github/workflows/smoke-UID2-7069.yaml diff --git a/.github/workflows/smoke-UID2-7069.yaml b/.github/workflows/smoke-UID2-7069.yaml new file mode 100644 index 00000000..36dab6d6 --- /dev/null +++ b/.github/workflows/smoke-UID2-7069.yaml @@ -0,0 +1,101 @@ +name: TEMP smoke - UID2-7069 (delete after capture) + +# Throwaway smoke harness for PR #233. Verifies on a real Linux runner: +# 1. shared_create_releases validation guard rejects an invalid publish_platform. +# 2. shared_create_releases validation guard accepts each canonical platform. +# 3. The composite's Prepare-changelog-template bash + jq pipeline produces +# valid configurationJson for every platform. +# +# Deliberately does NOT invoke the composite with is_release: 'true' because +# delete_draft_releases (called inside the composite) deletes ALL draft +# releases in the repo — would clobber any unreleased drafts sitting in +# uid2-shared-actions. The standalone template-prep job (3) covers the +# rendering on Linux without touching releases. +# +# Pattern matches UID2-7041 smoke commits: add, capture run, delete. + +on: + push: + branches: [jn-UID2-7069-harden-shared-create-releases] + workflow_dispatch: + +jobs: + validate_rejects_bad_platform: + name: Validation guard rejects invalid platform (expected to fail) + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@v4 + - name: Composite with publish_platform=docker (lowercase, INVALID) + uses: ./actions/shared_create_releases + with: + is_release: 'false' + new_version: 0.0.0-smoke + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_platform: docker + + validate_accepts_canonical_platforms: + name: Validation guard accepts ${{ matrix.platform }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + platform: [Docker, Maven, PyPI, NuGet, iOS] + steps: + - uses: actions/checkout@v4 + - name: Composite with publish_platform=${{ matrix.platform }} (is_release=false) + uses: ./actions/shared_create_releases + with: + is_release: 'false' + new_version: 0.0.0-smoke + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_platform: ${{ matrix.platform }} + + render_template_on_linux: + name: Render template on Linux for ${{ matrix.platform }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + platform: [Docker, Maven, PyPI, NuGet, iOS] + steps: + - uses: actions/checkout@v4 + - name: Extract and run composite's template-prep block + env: + PLATFORM: ${{ matrix.platform }} + REPO: smoke-pkg + VERSION: 0.0.0-smoke + TAGS: ghcr.io/smoke/img:0.0.0 + IMAGE_TAG: ghcr.io/smoke/img@sha256:deadbeef + run: | + set -euo pipefail + # Pull the case-statement block live from action.yaml. awk grabs + # everything between `case "$PLATFORM" in` and the matching `esac`, + # then sed strips the 8-column YAML indent so the heredoc EOF + # markers sit at bash column 0. + awk '/^[[:space:]]*case "\$PLATFORM" in[[:space:]]*$/,/^[[:space:]]*esac[[:space:]]*$/' \ + actions/shared_create_releases/action.yaml \ + | sed 's/^ //' > /tmp/case.sh + + # Compose the standalone script: the extracted case block, then the + # same placeholder substitution + jq invocation the composite uses. + { + echo 'set -euo pipefail' + cat /tmp/case.sh + cat <<'TAIL' + template="${template//__TAGS__/$TAGS}" + template="${template//__IMAGE_TAG__/$IMAGE_TAG}" + template="${template//__REPO__/$REPO}" + template="${template//__VERSION__/$VERSION}" + config=$(jq -nc --arg t "$template" --arg p ' - #{{TITLE}} - ( PR: ##{{NUMBER}} )' '{template: $t, pr_template: $p}') + echo "--- configurationJson ---" + echo "$config" + echo + echo "--- validity check ---" + echo "$config" | jq -e '.template | length > 0' >/dev/null && echo "OK: non-empty template, valid JSON" + echo + echo "--- decoded template ---" + echo "$config" | jq -r '.template' + TAIL + } > /tmp/run.sh + bash /tmp/run.sh From fc5ff519ba055983a89b457b5b0aa3855cbc38c6 Mon Sep 17 00:00:00 2001 From: jon8787 <112368577+jon8787@users.noreply.github.com> Date: Wed, 20 May 2026 15:02:36 +1000 Subject: [PATCH 05/12] UID2-7069: fix empty ${{ }} placeholder in composite comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- actions/shared_create_releases/action.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/actions/shared_create_releases/action.yaml b/actions/shared_create_releases/action.yaml index 4a7f037d..431725fd 100644 --- a/actions/shared_create_releases/action.yaml +++ b/actions/shared_create_releases/action.yaml @@ -147,7 +147,9 @@ runs: template="${template//__VERSION__/$VERSION}" # Compact JSON (single line) keeps the step output value on one line — - # simpler to pass into mikepenz's configurationJson input via ${{ }}. + # simpler to pass into mikepenz's configurationJson input via a + # GitHub Actions expression (no multi-line $GITHUB_OUTPUT delimiter + # needed downstream). config=$(jq -nc \ --arg t "$template" \ --arg p ' - #{{TITLE}} - ( PR: ##{{NUMBER}} )' \ From 776f73e0d7294860d3091632580977b35c0cf7fa Mon Sep 17 00:00:00 2001 From: jon8787 <112368577+jon8787@users.noreply.github.com> Date: Wed, 20 May 2026 15:05:48 +1000 Subject: [PATCH 06/12] UID2-7069: validate publish_platform in bash (case-sensitive) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- actions/shared_create_releases/action.yaml | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/actions/shared_create_releases/action.yaml b/actions/shared_create_releases/action.yaml index 431725fd..0fef6a4b 100644 --- a/actions/shared_create_releases/action.yaml +++ b/actions/shared_create_releases/action.yaml @@ -35,12 +35,26 @@ runs: # value (e.g. 'docker' lowercase) silently skips every Build Changelog # step, lets Delete Draft Releases run, and creates a draft release with # an empty body. See UID2-7069. + # + # Validation is done in bash (case statement) rather than via + # `contains(fromJSON([...]), inputs.publish_platform)` because GitHub + # Actions' contains() is case-INSENSITIVE — `contains(['Docker',...], + # 'docker')` returns true, so the expression form silently lets the + # exact typo we're trying to catch slip through. Bash `case` is + # case-sensitive by default. - name: Validate publish_platform - if: ${{ !contains(fromJSON('["Docker","Maven","PyPI","NuGet","iOS"]'), inputs.publish_platform) }} shell: bash + env: + PLATFORM: ${{ inputs.publish_platform }} run: | - echo "::error::Unsupported publish_platform '${{ inputs.publish_platform }}'. Must be one of [Docker, Maven, PyPI, NuGet, iOS]." - exit 1 + case "$PLATFORM" in + Docker|Maven|PyPI|NuGet|iOS) + ;; + *) + echo "::error::Unsupported publish_platform '$PLATFORM'. Must be one of [Docker, Maven, PyPI, NuGet, iOS]." + exit 1 + ;; + esac # Build the mikepenz configurationJson for the chosen platform. One step # replaces what used to be five near-identical Build X Changelog steps — From addfe5ea23e26403164bae66a273b70df4be6ad3 Mon Sep 17 00:00:00 2001 From: jon8787 <112368577+jon8787@users.noreply.github.com> Date: Wed, 20 May 2026 15:17:55 +1000 Subject: [PATCH 07/12] UID2-7069: TEMP smoke - Path C extract steps vs real fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .github/workflows/smoke-UID2-7069.yaml | 73 ++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/.github/workflows/smoke-UID2-7069.yaml b/.github/workflows/smoke-UID2-7069.yaml index 36dab6d6..a72241b7 100644 --- a/.github/workflows/smoke-UID2-7069.yaml +++ b/.github/workflows/smoke-UID2-7069.yaml @@ -51,6 +51,79 @@ jobs: github_token: ${{ secrets.GITHUB_TOKEN }} publish_platform: ${{ matrix.platform }} + # Path C: run the new Extract bash logic from each publish workflow + # against real consumer-repo fixtures, without actually publishing. + # Catches drift between the assumed package shape and what real repos use + # (e.g. uid2-client-python publishes "uid2_client" with an underscore, not + # "uid2-client" — exactly the kind of mismatch the new tomllib parser + # would have to handle right). + extract_maven_artifactid: + name: Extract Maven artifactId from uid2-attestation-aws fixture + runs-on: ubuntu-latest + steps: + - name: Checkout uid2-attestation-aws (fixture) + uses: actions/checkout@v4 + with: + repository: IABTechLab/uid2-attestation-aws + ref: main + path: fixture + - name: Set up JDK 11 (matches consumer's default) + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '11' + - name: Run the new Extract Maven artifactId logic + run: | + set -euo pipefail + # Same command used in shared-publish-to-maven-versioned.yaml. + # Consumer passes working_dir=attestation-aws, so the pom lives at + # fixture/attestation-aws/pom.xml here. + artifact_id=$(mvn -B -f fixture/attestation-aws/pom.xml help:evaluate -Dexpression=project.artifactId -q -DforceStdout) + echo "Extracted artifactId: $artifact_id" + test "$artifact_id" = "attestation-aws" + echo "OK: matches expected 'attestation-aws'" + + extract_pypi_name: + name: Extract PyPI name from uid2-client-python fixture + runs-on: ubuntu-latest + steps: + - name: Checkout uid2-client-python (fixture) + uses: actions/checkout@v4 + with: + repository: IABTechLab/uid2-client-python + ref: main + path: fixture + - name: Run the new tomllib-based PyPI name extraction + run: | + set -euo pipefail + # Same command used in shared-publish-to-pypi-versioned.yaml. + name=$(python3 -c "import sys, tomllib; print(tomllib.load(open(sys.argv[1], 'rb'))['project']['name'])" fixture/pyproject.toml) + echo "Extracted name: $name" + # uid2-client-python publishes as 'uid2_client' (underscore) — NOT + # 'uid2-client' as one might guess from the repo name. The new + # tomllib parser correctly returns whatever [project].name says. + test "$name" = "uid2_client" + echo "OK: matches expected 'uid2_client'" + + extract_nuget_id: + name: Extract NuGet id from uid2-client-net fixture + runs-on: ubuntu-latest + steps: + - name: Checkout uid2-client-net (fixture) + uses: actions/checkout@v4 + with: + repository: IABTechLab/uid2-client-net + ref: main + path: fixture + - name: Run the new xmllint-based NuGet id extraction + run: | + set -euo pipefail + # Same command used in shared-publish-to-nuget-versioned.yaml. + package_id=$(xmllint --xpath 'string(//*[local-name()="package"]/*[local-name()="metadata"]/*[local-name()="id"])' fixture/UID2.Client.nuspec) + echo "Extracted package id: $package_id" + test "$package_id" = "UID2.Client" + echo "OK: matches expected 'UID2.Client'" + render_template_on_linux: name: Render template on Linux for ${{ matrix.platform }} runs-on: ubuntu-latest From 4277ce485b03700ec13e2d7b6aaa95a2a663cd55 Mon Sep 17 00:00:00 2001 From: jon8787 <112368577+jon8787@users.noreply.github.com> Date: Wed, 20 May 2026 15:20:19 +1000 Subject: [PATCH 08/12] UID2-7069: install xmllint explicitly in NuGet workflow 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) --- .../workflows/shared-publish-to-nuget-versioned.yaml | 10 ++++++---- .github/workflows/smoke-UID2-7069.yaml | 2 ++ 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/shared-publish-to-nuget-versioned.yaml b/.github/workflows/shared-publish-to-nuget-versioned.yaml index 91fc5839..fca125f3 100644 --- a/.github/workflows/shared-publish-to-nuget-versioned.yaml +++ b/.github/workflows/shared-publish-to-nuget-versioned.yaml @@ -107,6 +107,10 @@ jobs: tag: v${{ steps.version.outputs.new_version }} github_token: ${{ inputs.merge_environment != '' && secrets.GH_MERGE_TOKEN || '' }} + - name: Install xmllint + if: ${{ steps.checkRelease.outputs.is_release == 'true' }} + run: sudo apt-get update -qq && sudo apt-get install -y -qq libxml2-utils + - name: Extract NuGet package id id: package_name if: ${{ steps.checkRelease.outputs.is_release == 'true' }} @@ -117,10 +121,8 @@ jobs: # use an `id` attribute (), not an # element, so this XPath matches only the package id. # - # REQUIRES xmllint, provided by libxml2-utils — preinstalled on - # ubuntu-latest. If a caller forks the workflow onto a minimal - # runner, add `sudo apt-get install -y libxml2-utils` before this - # step. + # xmllint (libxml2-utils) is NOT preinstalled on ubuntu-latest as + # of 2026 — installed explicitly in the previous step. # # NOTE: the .nuspec file path itself is still hardcoded throughout # this workflow (UID2.Client.nuspec). Fixing the package id only diff --git a/.github/workflows/smoke-UID2-7069.yaml b/.github/workflows/smoke-UID2-7069.yaml index a72241b7..b1ded24e 100644 --- a/.github/workflows/smoke-UID2-7069.yaml +++ b/.github/workflows/smoke-UID2-7069.yaml @@ -115,6 +115,8 @@ jobs: repository: IABTechLab/uid2-client-net ref: main path: fixture + - name: Install xmllint (libxml2-utils not preinstalled on ubuntu-latest) + run: sudo apt-get update -qq && sudo apt-get install -y -qq libxml2-utils - name: Run the new xmllint-based NuGet id extraction run: | set -euo pipefail From 88c9bb199dc49ebeb437a184dd3f1e998f67aebe Mon Sep 17 00:00:00 2001 From: jon8787 <112368577+jon8787@users.noreply.github.com> Date: Wed, 20 May 2026 15:32:01 +1000 Subject: [PATCH 09/12] UID2-7069: TEMP smoke - destructive end-to-end (is_release=true) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `smoke-pkg`. 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) --- .github/workflows/smoke-UID2-7069.yaml | 162 +++++++++++++++++++++++++ 1 file changed, 162 insertions(+) diff --git a/.github/workflows/smoke-UID2-7069.yaml b/.github/workflows/smoke-UID2-7069.yaml index b1ded24e..9c99dacf 100644 --- a/.github/workflows/smoke-UID2-7069.yaml +++ b/.github/workflows/smoke-UID2-7069.yaml @@ -18,6 +18,11 @@ on: push: branches: [jn-UID2-7069-harden-shared-create-releases] workflow_dispatch: + inputs: + run_destructive_e2e: + description: 'Also run the destructive is_release=true end-to-end (snapshots and restores all draft releases; creates and deletes a v0.0.0-smoke tag). Only set true when you intend to actually exercise the full composite.' + type: boolean + default: false jobs: validate_rejects_bad_platform: @@ -174,3 +179,160 @@ jobs: TAIL } > /tmp/run.sh bash /tmp/run.sh + + # End-to-end coverage of the composite's is_release=true path: the only + # smoke that actually invokes mikepenz, Delete Draft Releases, and Create + # Release in sequence. Destructive (it deletes all draft releases as a + # side effect of how the composite works), so it snapshots them before- + # hand and restores them in an `if: always()` cleanup step. + # + # workflow_dispatch only — do NOT auto-run on push. Run manually: + # gh workflow run smoke-UID2-7069.yaml \ + # --ref jn-UID2-7069-harden-shared-create-releases \ + # -f run_destructive_e2e=true + # + # Covers what the standalone render_template_on_linux jobs can't: the + # GHA $GITHUB_OUTPUT plumbing between Prepare-changelog-template and + # mikepenz, mikepenz's parsing of the new configurationJson string, and + # the body interpolation into softprops/action-gh-release. + end_to_end_is_release_true: + name: End-to-end composite (is_release=true, Maven, snapshot+restore) + if: ${{ github.event_name == 'workflow_dispatch' && inputs.run_destructive_e2e }} + runs-on: ubuntu-latest + permissions: + contents: write # tag operations + Create Release + Delete Draft Releases + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # need tags for mikepenz to compute a diff + + - name: Cleanup any leftover v0.0.0-smoke tag from a previous failed run + run: | + git push --delete origin v0.0.0-smoke 2>/dev/null || true + git tag -d v0.0.0-smoke 2>/dev/null || true + + - name: Pick fromTag (newest existing v-prefixed tag in repo) + id: pick_tag + run: | + set -euo pipefail + from_tag=$(git tag --sort=-version:refname | grep -E '^v[0-9]' | head -1) + if [ -z "$from_tag" ]; then + echo "ERROR: no v-prefixed tag found to use as fromTag" >&2 + exit 1 + fi + echo "Using fromTag=$from_tag" + echo "from_tag=$from_tag" >> "$GITHUB_OUTPUT" + + - name: Create temporary v0.0.0-smoke tag pointing at HEAD + run: | + git tag v0.0.0-smoke + git push origin v0.0.0-smoke + + - name: Snapshot existing draft releases + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + with: + script: | + const drafts = (await github.rest.repos.listReleases({ + owner: context.repo.owner, + repo: context.repo.repo, + per_page: 100, + })).data.filter(r => r.draft); + const snapshot = drafts.map(d => ({ + name: d.name, + tag_name: d.tag_name, + target_commitish: d.target_commitish, + body: d.body, + prerelease: d.prerelease, + draft: true, + })); + require('fs').writeFileSync( + process.env.GITHUB_WORKSPACE + '/drafts-snapshot.json', + JSON.stringify(snapshot), + ); + core.info(`Snapshotted ${snapshot.length} draft release(s)`); + + - name: Run composite with is_release=true publish_platform=Maven + uses: ./actions/shared_create_releases + with: + is_release: 'true' + new_version: 0.0.0-smoke + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_platform: Maven + repo: smoke-pkg + from_tag: ${{ steps.pick_tag.outputs.from_tag }} + + - name: Verify smoke draft release was created with Maven template content + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + with: + script: | + const releases = (await github.rest.repos.listReleases({ + owner: context.repo.owner, + repo: context.repo.repo, + per_page: 100, + })).data; + const smoke = releases.find(r => r.draft && r.name === 'v0.0.0-smoke'); + if (!smoke) { + throw new Error('Smoke draft release v0.0.0-smoke was NOT created by the composite'); + } + core.info(`Smoke draft id=${smoke.id}`); + core.info(`Body (first 1000 chars):\n${(smoke.body || '').slice(0, 1000)}`); + if (!smoke.body || smoke.body.length === 0) { + throw new Error('Smoke draft body is EMPTY — template flow broken'); + } + const expectedMarker = 'smoke-pkg'; + if (!smoke.body.includes(expectedMarker)) { + throw new Error(`Body missing expected marker "${expectedMarker}"`); + } + core.info(`OK: Maven template content present in draft body`); + + - name: Cleanup — delete smoke draft + restore snapshotted drafts + if: always() + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + with: + script: | + // Delete v0.0.0-smoke draft (it survived the composite, or was + // never created — either way clean up). + const releases = (await github.rest.repos.listReleases({ + owner: context.repo.owner, + repo: context.repo.repo, + per_page: 100, + })).data; + for (const r of releases.filter(r => r.draft && r.name === 'v0.0.0-smoke')) { + try { + await github.rest.repos.deleteRelease({ + owner: context.repo.owner, + repo: context.repo.repo, + release_id: r.id, + }); + core.info(`Deleted smoke draft id=${r.id}`); + } catch (e) { + core.warning(`Failed to delete smoke draft id=${r.id}: ${e.message}`); + } + } + // Restore snapshotted drafts (Delete Draft Releases nuked them + // mid-run, so they need to come back). + const fs = require('fs'); + const path = process.env.GITHUB_WORKSPACE + '/drafts-snapshot.json'; + if (fs.existsSync(path)) { + const snapshot = JSON.parse(fs.readFileSync(path, 'utf8')); + for (const d of snapshot) { + try { + await github.rest.repos.createRelease({ + owner: context.repo.owner, + repo: context.repo.repo, + ...d, + }); + core.info(`Restored draft: ${d.name || d.tag_name}`); + } catch (e) { + core.warning(`Failed to restore draft ${d.name || d.tag_name}: ${e.message}`); + } + } + core.info(`Restore pass complete (${snapshot.length} draft(s) attempted)`); + } else { + core.warning('drafts-snapshot.json not found — snapshot step likely failed; no restore possible'); + } + + - name: Delete temporary v0.0.0-smoke tag + if: always() + run: | + git push --delete origin v0.0.0-smoke 2>/dev/null || true From ff7148bec01a5169457fc504c5b8bdb400dfaab9 Mon Sep 17 00:00:00 2001 From: jon8787 <112368577+jon8787@users.noreply.github.com> Date: Wed, 20 May 2026 15:36:09 +1000 Subject: [PATCH 10/12] UID2-7069: TEMP smoke - use older fromTag so mikepenz has merged PRs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .github/workflows/smoke-UID2-7069.yaml | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/.github/workflows/smoke-UID2-7069.yaml b/.github/workflows/smoke-UID2-7069.yaml index 9c99dacf..0c13a371 100644 --- a/.github/workflows/smoke-UID2-7069.yaml +++ b/.github/workflows/smoke-UID2-7069.yaml @@ -211,16 +211,30 @@ jobs: git push --delete origin v0.0.0-smoke 2>/dev/null || true git tag -d v0.0.0-smoke 2>/dev/null || true - - name: Pick fromTag (newest existing v-prefixed tag in repo) + - name: Pick fromTag (5th-newest v-prefixed tag — needs merged PRs in range) id: pick_tag run: | set -euo pipefail - from_tag=$(git tag --sort=-version:refname | grep -E '^v[0-9]' | head -1) + # If we use the newest tag as fromTag and HEAD as toTag with HEAD + # being a feature branch with no merged PRs since the newest tag, + # mikepenz hits its empty_template fallback ("- no changes") and + # the entire platform template gets discarded. To exercise the + # template-substitution path, pick a tag several releases back so + # there are guaranteed merged PRs in the range. + from_tag=$(git tag --sort=-version:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -5 | tail -1) + if [ -z "$from_tag" ]; then + # Fallback to plain v[0-9]+ pattern if SemVer didn't match + from_tag=$(git tag --sort=-version:refname | grep -E '^v[0-9]' | head -5 | tail -1) + fi if [ -z "$from_tag" ]; then echo "ERROR: no v-prefixed tag found to use as fromTag" >&2 exit 1 fi - echo "Using fromTag=$from_tag" + merges=$(git log "$from_tag..HEAD" --merges --oneline | wc -l) + echo "Using fromTag=$from_tag (merged PRs in range: $merges)" + if [ "$merges" -lt 1 ]; then + echo "WARNING: no merged PRs in $from_tag..HEAD — mikepenz will fall back to empty_template and the body verification will fail." >&2 + fi echo "from_tag=$from_tag" >> "$GITHUB_OUTPUT" - name: Create temporary v0.0.0-smoke tag pointing at HEAD From 32e492084bb033ae46f30a8969da911912214da8 Mon Sep 17 00:00:00 2001 From: jon8787 <112368577+jon8787@users.noreply.github.com> Date: Wed, 20 May 2026 15:41:45 +1000 Subject: [PATCH 11/12] @ UID2-7069: drop smoke harness; runs captured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) @ --- .github/workflows/smoke-UID2-7069.yaml | 352 ------------------------- 1 file changed, 352 deletions(-) delete mode 100644 .github/workflows/smoke-UID2-7069.yaml diff --git a/.github/workflows/smoke-UID2-7069.yaml b/.github/workflows/smoke-UID2-7069.yaml deleted file mode 100644 index 0c13a371..00000000 --- a/.github/workflows/smoke-UID2-7069.yaml +++ /dev/null @@ -1,352 +0,0 @@ -name: TEMP smoke - UID2-7069 (delete after capture) - -# Throwaway smoke harness for PR #233. Verifies on a real Linux runner: -# 1. shared_create_releases validation guard rejects an invalid publish_platform. -# 2. shared_create_releases validation guard accepts each canonical platform. -# 3. The composite's Prepare-changelog-template bash + jq pipeline produces -# valid configurationJson for every platform. -# -# Deliberately does NOT invoke the composite with is_release: 'true' because -# delete_draft_releases (called inside the composite) deletes ALL draft -# releases in the repo — would clobber any unreleased drafts sitting in -# uid2-shared-actions. The standalone template-prep job (3) covers the -# rendering on Linux without touching releases. -# -# Pattern matches UID2-7041 smoke commits: add, capture run, delete. - -on: - push: - branches: [jn-UID2-7069-harden-shared-create-releases] - workflow_dispatch: - inputs: - run_destructive_e2e: - description: 'Also run the destructive is_release=true end-to-end (snapshots and restores all draft releases; creates and deletes a v0.0.0-smoke tag). Only set true when you intend to actually exercise the full composite.' - type: boolean - default: false - -jobs: - validate_rejects_bad_platform: - name: Validation guard rejects invalid platform (expected to fail) - runs-on: ubuntu-latest - continue-on-error: true - steps: - - uses: actions/checkout@v4 - - name: Composite with publish_platform=docker (lowercase, INVALID) - uses: ./actions/shared_create_releases - with: - is_release: 'false' - new_version: 0.0.0-smoke - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_platform: docker - - validate_accepts_canonical_platforms: - name: Validation guard accepts ${{ matrix.platform }} - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - platform: [Docker, Maven, PyPI, NuGet, iOS] - steps: - - uses: actions/checkout@v4 - - name: Composite with publish_platform=${{ matrix.platform }} (is_release=false) - uses: ./actions/shared_create_releases - with: - is_release: 'false' - new_version: 0.0.0-smoke - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_platform: ${{ matrix.platform }} - - # Path C: run the new Extract bash logic from each publish workflow - # against real consumer-repo fixtures, without actually publishing. - # Catches drift between the assumed package shape and what real repos use - # (e.g. uid2-client-python publishes "uid2_client" with an underscore, not - # "uid2-client" — exactly the kind of mismatch the new tomllib parser - # would have to handle right). - extract_maven_artifactid: - name: Extract Maven artifactId from uid2-attestation-aws fixture - runs-on: ubuntu-latest - steps: - - name: Checkout uid2-attestation-aws (fixture) - uses: actions/checkout@v4 - with: - repository: IABTechLab/uid2-attestation-aws - ref: main - path: fixture - - name: Set up JDK 11 (matches consumer's default) - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: '11' - - name: Run the new Extract Maven artifactId logic - run: | - set -euo pipefail - # Same command used in shared-publish-to-maven-versioned.yaml. - # Consumer passes working_dir=attestation-aws, so the pom lives at - # fixture/attestation-aws/pom.xml here. - artifact_id=$(mvn -B -f fixture/attestation-aws/pom.xml help:evaluate -Dexpression=project.artifactId -q -DforceStdout) - echo "Extracted artifactId: $artifact_id" - test "$artifact_id" = "attestation-aws" - echo "OK: matches expected 'attestation-aws'" - - extract_pypi_name: - name: Extract PyPI name from uid2-client-python fixture - runs-on: ubuntu-latest - steps: - - name: Checkout uid2-client-python (fixture) - uses: actions/checkout@v4 - with: - repository: IABTechLab/uid2-client-python - ref: main - path: fixture - - name: Run the new tomllib-based PyPI name extraction - run: | - set -euo pipefail - # Same command used in shared-publish-to-pypi-versioned.yaml. - name=$(python3 -c "import sys, tomllib; print(tomllib.load(open(sys.argv[1], 'rb'))['project']['name'])" fixture/pyproject.toml) - echo "Extracted name: $name" - # uid2-client-python publishes as 'uid2_client' (underscore) — NOT - # 'uid2-client' as one might guess from the repo name. The new - # tomllib parser correctly returns whatever [project].name says. - test "$name" = "uid2_client" - echo "OK: matches expected 'uid2_client'" - - extract_nuget_id: - name: Extract NuGet id from uid2-client-net fixture - runs-on: ubuntu-latest - steps: - - name: Checkout uid2-client-net (fixture) - uses: actions/checkout@v4 - with: - repository: IABTechLab/uid2-client-net - ref: main - path: fixture - - name: Install xmllint (libxml2-utils not preinstalled on ubuntu-latest) - run: sudo apt-get update -qq && sudo apt-get install -y -qq libxml2-utils - - name: Run the new xmllint-based NuGet id extraction - run: | - set -euo pipefail - # Same command used in shared-publish-to-nuget-versioned.yaml. - package_id=$(xmllint --xpath 'string(//*[local-name()="package"]/*[local-name()="metadata"]/*[local-name()="id"])' fixture/UID2.Client.nuspec) - echo "Extracted package id: $package_id" - test "$package_id" = "UID2.Client" - echo "OK: matches expected 'UID2.Client'" - - render_template_on_linux: - name: Render template on Linux for ${{ matrix.platform }} - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - platform: [Docker, Maven, PyPI, NuGet, iOS] - steps: - - uses: actions/checkout@v4 - - name: Extract and run composite's template-prep block - env: - PLATFORM: ${{ matrix.platform }} - REPO: smoke-pkg - VERSION: 0.0.0-smoke - TAGS: ghcr.io/smoke/img:0.0.0 - IMAGE_TAG: ghcr.io/smoke/img@sha256:deadbeef - run: | - set -euo pipefail - # Pull the case-statement block live from action.yaml. awk grabs - # everything between `case "$PLATFORM" in` and the matching `esac`, - # then sed strips the 8-column YAML indent so the heredoc EOF - # markers sit at bash column 0. - awk '/^[[:space:]]*case "\$PLATFORM" in[[:space:]]*$/,/^[[:space:]]*esac[[:space:]]*$/' \ - actions/shared_create_releases/action.yaml \ - | sed 's/^ //' > /tmp/case.sh - - # Compose the standalone script: the extracted case block, then the - # same placeholder substitution + jq invocation the composite uses. - { - echo 'set -euo pipefail' - cat /tmp/case.sh - cat <<'TAIL' - template="${template//__TAGS__/$TAGS}" - template="${template//__IMAGE_TAG__/$IMAGE_TAG}" - template="${template//__REPO__/$REPO}" - template="${template//__VERSION__/$VERSION}" - config=$(jq -nc --arg t "$template" --arg p ' - #{{TITLE}} - ( PR: ##{{NUMBER}} )' '{template: $t, pr_template: $p}') - echo "--- configurationJson ---" - echo "$config" - echo - echo "--- validity check ---" - echo "$config" | jq -e '.template | length > 0' >/dev/null && echo "OK: non-empty template, valid JSON" - echo - echo "--- decoded template ---" - echo "$config" | jq -r '.template' - TAIL - } > /tmp/run.sh - bash /tmp/run.sh - - # End-to-end coverage of the composite's is_release=true path: the only - # smoke that actually invokes mikepenz, Delete Draft Releases, and Create - # Release in sequence. Destructive (it deletes all draft releases as a - # side effect of how the composite works), so it snapshots them before- - # hand and restores them in an `if: always()` cleanup step. - # - # workflow_dispatch only — do NOT auto-run on push. Run manually: - # gh workflow run smoke-UID2-7069.yaml \ - # --ref jn-UID2-7069-harden-shared-create-releases \ - # -f run_destructive_e2e=true - # - # Covers what the standalone render_template_on_linux jobs can't: the - # GHA $GITHUB_OUTPUT plumbing between Prepare-changelog-template and - # mikepenz, mikepenz's parsing of the new configurationJson string, and - # the body interpolation into softprops/action-gh-release. - end_to_end_is_release_true: - name: End-to-end composite (is_release=true, Maven, snapshot+restore) - if: ${{ github.event_name == 'workflow_dispatch' && inputs.run_destructive_e2e }} - runs-on: ubuntu-latest - permissions: - contents: write # tag operations + Create Release + Delete Draft Releases - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 # need tags for mikepenz to compute a diff - - - name: Cleanup any leftover v0.0.0-smoke tag from a previous failed run - run: | - git push --delete origin v0.0.0-smoke 2>/dev/null || true - git tag -d v0.0.0-smoke 2>/dev/null || true - - - name: Pick fromTag (5th-newest v-prefixed tag — needs merged PRs in range) - id: pick_tag - run: | - set -euo pipefail - # If we use the newest tag as fromTag and HEAD as toTag with HEAD - # being a feature branch with no merged PRs since the newest tag, - # mikepenz hits its empty_template fallback ("- no changes") and - # the entire platform template gets discarded. To exercise the - # template-substitution path, pick a tag several releases back so - # there are guaranteed merged PRs in the range. - from_tag=$(git tag --sort=-version:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -5 | tail -1) - if [ -z "$from_tag" ]; then - # Fallback to plain v[0-9]+ pattern if SemVer didn't match - from_tag=$(git tag --sort=-version:refname | grep -E '^v[0-9]' | head -5 | tail -1) - fi - if [ -z "$from_tag" ]; then - echo "ERROR: no v-prefixed tag found to use as fromTag" >&2 - exit 1 - fi - merges=$(git log "$from_tag..HEAD" --merges --oneline | wc -l) - echo "Using fromTag=$from_tag (merged PRs in range: $merges)" - if [ "$merges" -lt 1 ]; then - echo "WARNING: no merged PRs in $from_tag..HEAD — mikepenz will fall back to empty_template and the body verification will fail." >&2 - fi - echo "from_tag=$from_tag" >> "$GITHUB_OUTPUT" - - - name: Create temporary v0.0.0-smoke tag pointing at HEAD - run: | - git tag v0.0.0-smoke - git push origin v0.0.0-smoke - - - name: Snapshot existing draft releases - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 - with: - script: | - const drafts = (await github.rest.repos.listReleases({ - owner: context.repo.owner, - repo: context.repo.repo, - per_page: 100, - })).data.filter(r => r.draft); - const snapshot = drafts.map(d => ({ - name: d.name, - tag_name: d.tag_name, - target_commitish: d.target_commitish, - body: d.body, - prerelease: d.prerelease, - draft: true, - })); - require('fs').writeFileSync( - process.env.GITHUB_WORKSPACE + '/drafts-snapshot.json', - JSON.stringify(snapshot), - ); - core.info(`Snapshotted ${snapshot.length} draft release(s)`); - - - name: Run composite with is_release=true publish_platform=Maven - uses: ./actions/shared_create_releases - with: - is_release: 'true' - new_version: 0.0.0-smoke - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_platform: Maven - repo: smoke-pkg - from_tag: ${{ steps.pick_tag.outputs.from_tag }} - - - name: Verify smoke draft release was created with Maven template content - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 - with: - script: | - const releases = (await github.rest.repos.listReleases({ - owner: context.repo.owner, - repo: context.repo.repo, - per_page: 100, - })).data; - const smoke = releases.find(r => r.draft && r.name === 'v0.0.0-smoke'); - if (!smoke) { - throw new Error('Smoke draft release v0.0.0-smoke was NOT created by the composite'); - } - core.info(`Smoke draft id=${smoke.id}`); - core.info(`Body (first 1000 chars):\n${(smoke.body || '').slice(0, 1000)}`); - if (!smoke.body || smoke.body.length === 0) { - throw new Error('Smoke draft body is EMPTY — template flow broken'); - } - const expectedMarker = 'smoke-pkg'; - if (!smoke.body.includes(expectedMarker)) { - throw new Error(`Body missing expected marker "${expectedMarker}"`); - } - core.info(`OK: Maven template content present in draft body`); - - - name: Cleanup — delete smoke draft + restore snapshotted drafts - if: always() - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 - with: - script: | - // Delete v0.0.0-smoke draft (it survived the composite, or was - // never created — either way clean up). - const releases = (await github.rest.repos.listReleases({ - owner: context.repo.owner, - repo: context.repo.repo, - per_page: 100, - })).data; - for (const r of releases.filter(r => r.draft && r.name === 'v0.0.0-smoke')) { - try { - await github.rest.repos.deleteRelease({ - owner: context.repo.owner, - repo: context.repo.repo, - release_id: r.id, - }); - core.info(`Deleted smoke draft id=${r.id}`); - } catch (e) { - core.warning(`Failed to delete smoke draft id=${r.id}: ${e.message}`); - } - } - // Restore snapshotted drafts (Delete Draft Releases nuked them - // mid-run, so they need to come back). - const fs = require('fs'); - const path = process.env.GITHUB_WORKSPACE + '/drafts-snapshot.json'; - if (fs.existsSync(path)) { - const snapshot = JSON.parse(fs.readFileSync(path, 'utf8')); - for (const d of snapshot) { - try { - await github.rest.repos.createRelease({ - owner: context.repo.owner, - repo: context.repo.repo, - ...d, - }); - core.info(`Restored draft: ${d.name || d.tag_name}`); - } catch (e) { - core.warning(`Failed to restore draft ${d.name || d.tag_name}: ${e.message}`); - } - } - core.info(`Restore pass complete (${snapshot.length} draft(s) attempted)`); - } else { - core.warning('drafts-snapshot.json not found — snapshot step likely failed; no restore possible'); - } - - - name: Delete temporary v0.0.0-smoke tag - if: always() - run: | - git push --delete origin v0.0.0-smoke 2>/dev/null || true From e0b5a4988e4439c9c48cc5192500a801877b0231 Mon Sep 17 00:00:00 2001 From: jon8787 <112368577+jon8787@users.noreply.github.com> Date: Wed, 20 May 2026 15:54:36 +1000 Subject: [PATCH 12/12] UID2-7069: address PR #233 reviewer doc nits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../shared-publish-to-pypi-versioned.yaml | 9 ++++++++- actions/shared_create_releases/action.yaml | 16 ++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/.github/workflows/shared-publish-to-pypi-versioned.yaml b/.github/workflows/shared-publish-to-pypi-versioned.yaml index 77524cc3..582d6905 100644 --- a/.github/workflows/shared-publish-to-pypi-versioned.yaml +++ b/.github/workflows/shared-publish-to-pypi-versioned.yaml @@ -102,7 +102,7 @@ jobs: if: ${{ steps.checkRelease.outputs.is_release == 'true' }} run: | # PyPI package name comes from pyproject.toml, not the github repo name - # (e.g. uid2-client-python repo publishes the `uid2-client` package). + # (e.g. uid2-client-python repo publishes the `uid2_client` package). # Use tomllib so we correctly handle both single- and double-quoted # values, ignore unrelated `name =` keys in earlier tables (e.g. # dependency tables), and fail fast if [project].name is missing. @@ -113,6 +113,13 @@ jobs: # currently ships Python 3.12, so this is safe today; if a caller # ever forks the workflow onto ubuntu-22.04 (Python 3.10), add an # actions/setup-python step before this one or fall back to `tomli`. + # + # Assumes PEP 621 layout — i.e. the package name lives at + # [project].name. Poetry-style projects with [tool.poetry] would + # need a different key path (e.g. ['tool']['poetry']['name']); + # the KeyError this would raise is loud but unhelpful. No current + # PyPI consumer uses Poetry, so leaving the PEP 621 assumption + # in place. name=$(python3 -c "import sys, tomllib; print(tomllib.load(open(sys.argv[1], 'rb'))['project']['name'])" "${{ inputs.working_dir }}/pyproject.toml") if [ -z "$name" ]; then echo "ERROR: could not extract [project].name from ${{ inputs.working_dir }}/pyproject.toml" >&2 diff --git a/actions/shared_create_releases/action.yaml b/actions/shared_create_releases/action.yaml index 0fef6a4b..df6675bf 100644 --- a/actions/shared_create_releases/action.yaml +++ b/actions/shared_create_releases/action.yaml @@ -42,6 +42,10 @@ runs: # 'docker')` returns true, so the expression form silently lets the # exact typo we're trying to catch slip through. Bash `case` is # case-sensitive by default. + # + # Intentionally NOT gated on is_release — typos should be loud on + # Snapshot runs too, so they're caught before someone tries to cut + # a real release with the same misconfiguration. - name: Validate publish_platform shell: bash env: @@ -74,6 +78,11 @@ runs: # Heredocs use quoted EOF so backticks and $ stay literal — no shell # expansion inside the template body. We then substitute the four # placeholders via bash parameter expansion. + # + # Heredoc body lines sit at YAML's common-prefix indent (8 spaces) + # so YAML's literal-block-scalar dedent leaves them flush-left in + # the actual bash script — that's what lets the closing EOF marker + # match. If you re-indent the heredoc body, the heredoc breaks. case "$PLATFORM" in Docker) template=$(cat <<'EOF' @@ -155,6 +164,13 @@ runs: ;; esac + # Placeholder tokens (__TAGS__, __IMAGE_TAG__, __REPO__, __VERSION__) + # are reserved — no input value should contain them. Substitution is + # order-dependent: if e.g. $TAGS expanded to literal `__REPO__`, the + # next line would re-substitute it. All inputs currently come from + # trusted CI sources (docker/metadata-action outputs, semver), so + # this is theoretical. Keep the tokens unique-looking if you add + # more. template="${template//__TAGS__/$TAGS}" template="${template//__IMAGE_TAG__/$IMAGE_TAG}" template="${template//__REPO__/$REPO}"