Skip to content

ci(release): carry release.yml's tag as data instead of script text - #168

Merged
minekube-ai-engineer[bot] merged 1 commit into
mainfrom
fix/release-tag-shell-boundary
Sep 27, 2026
Merged

minekube-ai-engineer[bot] merged 1 commit into
mainfrom
fix/release-tag-shell-boundary

Conversation

@minekube-ai-engineer

Copy link
Copy Markdown

Root cause

release.yml's "Resolve release tag" step read two values that the repository does not control and
wrote them into its run: body as script text:

run: |
  TAG="${{ inputs.release_tag }}"
  if [ -z "$TAG" ]; then
    TAG="${{ github.event.release.tag_name }}"
  fi

The runner expands an expression inside a run: body as text before bash parses it, so the value
becomes shell source. The double quotes stop an apostrophe from closing the literal (the defect that
stalled the release-please auto-merge step in #166), but they do not make the value data: a " in it
ends the literal, and the rest is parsed as shell code. Both values are attacker-shaped rather than
merely user-shaped — inputs.release_tag is caller-supplied on workflow_dispatch, and a git ref
may legally contain ", $ and backticks
— and this step runs in the job that declares
contents: write and hosts the Modrinth and Hangar publishing steps.

Reproduced with the checked-in wording and the value 0.15.14"; touch "$CANARY"; : " (the runner's
textual expansion is the only thing done by hand):

=== historical ===
--- the script bash actually parses:
    TAG="0.15.14"; touch "$CANARY"; : ""
--- exit code: 0
--- tag written to GITHUB_OUTPUT: tag=0.15.14
--- INJECTED COMMAND RAN (canary created)

=== fixed ===
--- the script bash actually parses:
    set -euo pipefail
    TAG="$RELEASE_TAG"
--- exit code: 0
--- tag written to GITHUB_OUTPUT: tag=0.15.14"; touch "$CANARY"; : "
--- no injected command ran

The historical shape is worse than a crash: the step stays green, runs the injected command, and
reports a truncated tag. steps.release-tag.outputs.tag then selects the release that is uploaded,
verified, and propagated to Hangar and Modrinth. With ~-style values (${VAR}, backslashes) the
same code path silently reports a corrupted tag and no command runs at all.

What changed

  1. release.yml — the step's tag now reaches the script as data:
      - name: Resolve release tag
        id: release-tag
        if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
        env:
          RELEASE_TAG: ${{ inputs.release_tag || github.event.release.tag_name }}
        run: |
          set -euo pipefail
          TAG="$RELEASE_TAG"
          if [ -z "$TAG" ]; then
            TAG="${GITHUB_REF_NAME}"
          fi
          echo "tag=$TAG" >> "$GITHUB_OUTPUT"
          echo "Release tag: $TAG"

Resolution order is unchanged (dispatch input → the release's own tag name → the ref this run was
dispatched on; set -u makes a missing env entry fail the step instead of silently publishing under
the run's ref name). Step name and id: release-tag are unchanged, so the steps that read
steps.release-tag.outputs.tag and the three tests that pin this workflow's step names are
untouched. The run: body no longer contains a single ${{ }}.

  1. The invariant is now repo-wide (the durable part). The scan that fix(release): don't let a merged commit's apostrophe stall the release-please auto-merge step #166 added to
    ReleasePleasePayloadTest moved into one shared implementation, WorkflowRunBlockPolicy, and
    ReleaseWorkflowShellBoundaryTest asserts it over every .github/workflows/*.y[a]ml — with
    the expected file set asserted first, so a sweep that silently stops seeing a file cannot pass as
    a green no-op. core/build.gradle.kts declares the whole workflow directory as a tasks.test
    input, so editing any workflow re-runs the suite instead of being served from Gradle's cache.
    Adding a workflow file is covered automatically.

  2. ReleaseWorkflowShellBoundaryTest (new) pins the step and executes it:

    • the step's env/run contract, including precedence and the ref-name fallback;
    • a harness that runs the step's own run: block with a stubbed runner context (values exported
      from files, never spliced — the first draft of the harness had the same defect and the payload
      ran inside the harness itself) for four paths: dispatch with an input, dispatch without one,
      a release event, and a release tag with build metadata. It asserts the fixed step writes the
      same tag= bytes the historical step wrote;
    • the shell-metacharacter cases (", $( ), backtick, ${ }, backslash) on both paths: the
      fixed step preserves the value byte for byte and runs nothing, while the historical step is
      asserted to still execute it / corrupt it with a green exit code;
    • the historical form is rebuilt from the current workflow (not pasted) and asserted to be
      caught by the policy by name, so the RED the fix started from stays visible;
    • 9 weakening mutations must each be rejected with a policy-prefixed reason:
      shell-interpolated-dispatch-input, shell-interpolated-release-tag-name, tag-env-removed,
      precedence-swapped, ref-name-fallback-removed, unquoted-env-read, output-write-removed,
      env-read-echoed-inert, tag-env-from-a-step-output-instead-of-the-input.
  3. .agents/skills/release/SKILL.md — the contract now says any workflow, and documents this
    step's env/run/resolution contract and the test that pins it.

RED → GREEN

RED on the unfixed tree (./gradlew :core:test --tests '*ReleaseWorkflowShellBoundaryTest*', 6/6
failed):

release-workflow-shell-policy: release.yml job "build" step "Resolve release tag" interpolates an
expression into its shell script, so a value containing a quote becomes shell code:
TAG="${{ inputs.release_tag }}"
release-workflow-shell-policy: ... TAG="${{ github.event.release.tag_name }}"
"Resolve release tag" must carry the tag as data in its RELEASE_TAG env, from
${{ inputs.release_tag || github.event.release.tag_name }} (found: null)
the tag value executed as shell code:            <- hostile dispatch input, unfixed workflow

GREEN after the fix: 6/6 pass, 9/9 mutations rejected, and the full ./gradlew build is
BUILD SUCCESSFUL with 430 tests across core/spigot/bungee/velocity.

Scope decision (card item 4)

The inputs.release_tag injection is worth a source fix, and the card's premise that it lands in
a read-only job is not correct: release.yml has a single build job that declares
contents: write (it uploads the release assets) and hosts the Publish to Hangar /
Publish to Modrinth steps, so injected text runs with a write-scoped GITHUB_TOKEN. The
dispatch-permission precondition still bounds who can supply the value, which is why this is
hardening rather than an incident — but the cost is four lines plus a test, and the same edit makes
the invariant repo-wide, which is the part that keeps the class from coming back.

release-repair.yml (and pullrequest.yml) are not touched, and do not need to be: every one of
their run: blocks already reads RELEASE_TAG / other values from job- or step-level env:, which
is why the repo-wide sweep passes over them. They are now covered by the sweep, so a future edit that
reintroduces the pattern fails the suite. Re-running the sweep over all workflows reports exactly
these two historical lines and nothing else.

No production mutation: source change on a trust-boundary workflow, plus tests.

Release impact

Title prefix is ci:, not fix: — the change touches a workflow and test sources only, so the
shipped plugin bytes are unchanged and no release needs to be cut for it. The new harness does
execute the step's own run: block in CI (pullrequest.yml runs ./gradlew build), and the first
real release after this merge exercises the same script.

@minekube-ai-engineer
minekube-ai-engineer Bot merged commit 6ade97c into main Sep 27, 2026
2 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.

0 participants