From 670da52fac38aa79efd4813c3430018e95d55192 Mon Sep 17 00:00:00 2001 From: olegbrok Date: Fri, 7 Aug 2026 08:09:24 -0700 Subject: [PATCH 1/2] Harden release workflow shell inputs --- .github/workflows/release.yml | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 98054175..cb37a315 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,14 +35,17 @@ jobs: steps: - name: Check version format + branch id: check + env: + RELEASE_VERSION: ${{ inputs.version }} + WORKFLOW_REF: ${{ github.ref }} run: | - version="${{ inputs.version }}" + version="$RELEASE_VERSION" if [[ ! "$version" =~ ^[0-9]{2}\.[0-9]{2}\.[0-9]{2,3}$ ]]; then echo "::error::Version must be calver YY.MM.NNN (e.g. 26.05.071), got: $version" exit 1 fi - if [[ "${{ github.ref }}" != "refs/heads/main" ]]; then - echo "::error::Releases can only be cut from main (got: ${{ github.ref }})" + if [[ "$WORKFLOW_REF" != "refs/heads/main" ]]; then + echo "::error::Releases can only be cut from main (got: $WORKFLOW_REF)" exit 1 fi echo "version=$version" >> "$GITHUB_OUTPUT" @@ -62,24 +65,28 @@ jobs: git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - name: Verify tag doesn't exist + env: + RELEASE_VERSION: ${{ needs.validate.outputs.version }} run: | - if git rev-parse "${{ needs.validate.outputs.version }}" >/dev/null 2>&1; then - echo "::error::Tag ${{ needs.validate.outputs.version }} already exists" + if git rev-parse "$RELEASE_VERSION" >/dev/null 2>&1; then + echo "::error::Tag $RELEASE_VERSION already exists" exit 1 fi - name: Generate release notes id: notes + env: + RELEASE_VERSION: ${{ needs.validate.outputs.version }} + CUSTOM_NOTES: ${{ inputs.release_notes }} run: | - version="${{ needs.validate.outputs.version }}" - custom_notes="${{ inputs.release_notes }}" + version="$RELEASE_VERSION" prior_tag=$(git describe --tags --abbrev=0 2>/dev/null || echo "") { echo "## Release $version" echo - if [ -n "$custom_notes" ]; then - echo "$custom_notes" + if [ -n "$CUSTOM_NOTES" ]; then + printf '%s\n' "$CUSTOM_NOTES" echo fi echo "### Changes" @@ -101,10 +108,11 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Create annotated tag + env: + RELEASE_VERSION: ${{ needs.validate.outputs.version }} run: | - version="${{ needs.validate.outputs.version }}" - git tag -a "$version" -m "Release $version" - git push origin "$version" + git tag -a "$RELEASE_VERSION" -m "Release $RELEASE_VERSION" + git push origin "$RELEASE_VERSION" - name: Create GitHub Release uses: softprops/action-gh-release@v3 From a9c498e3de1e78cb7f30f6d45905aad68ca9921b Mon Sep 17 00:00:00 2001 From: olegbrok Date: Fri, 7 Aug 2026 08:16:19 -0700 Subject: [PATCH 2/2] Publish release notes from a file --- .github/workflows/release.yml | 10 +----- tests/test_release_workflow.py | 61 ++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 9 deletions(-) create mode 100644 tests/test_release_workflow.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cb37a315..dcbded23 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -74,7 +74,6 @@ jobs: fi - name: Generate release notes - id: notes env: RELEASE_VERSION: ${{ needs.validate.outputs.version }} CUSTOM_NOTES: ${{ inputs.release_notes }} @@ -100,13 +99,6 @@ jobs: echo "Production hosts running \`PINKYBOT_CHANNEL=stable\` (the only channel) will pull this tag on the next \`update_and_restart\`." } > /tmp/notes.md - # Multiline output via heredoc - { - echo 'body<> "$GITHUB_OUTPUT" - - name: Create annotated tag env: RELEASE_VERSION: ${{ needs.validate.outputs.version }} @@ -119,6 +111,6 @@ jobs: with: tag_name: ${{ needs.validate.outputs.version }} name: ${{ needs.validate.outputs.version }} - body: ${{ steps.notes.outputs.body }} + body_path: /tmp/notes.md draft: false prerelease: false diff --git a/tests/test_release_workflow.py b/tests/test_release_workflow.py new file mode 100644 index 00000000..20f43eb9 --- /dev/null +++ b/tests/test_release_workflow.py @@ -0,0 +1,61 @@ +import os +import shlex +import subprocess +from pathlib import Path + +import yaml + +WORKFLOW_PATH = Path(__file__).parents[1] / ".github" / "workflows" / "release.yml" + + +def _release_steps() -> list[dict[str, object]]: + workflow = yaml.load(WORKFLOW_PATH.read_text(), Loader=yaml.BaseLoader) + return workflow["jobs"]["release"]["steps"] + + +def test_run_blocks_do_not_contain_workflow_expressions(): + workflow = yaml.load(WORKFLOW_PATH.read_text(), Loader=yaml.BaseLoader) + + for job in workflow["jobs"].values(): + for step in job["steps"]: + assert "${{" not in step.get("run", "") + + +def test_release_notes_are_written_literally_to_release_body_file(tmp_path): + steps = _release_steps() + generate = next(step for step in steps if step.get("name") == "Generate release notes") + publish = next(step for step in steps if step.get("name") == "Create GitHub Release") + + assert generate["env"]["CUSTOM_NOTES"] == "${{ inputs.release_notes }}" + assert "GITHUB_OUTPUT" not in generate["run"] + assert publish["with"]["body_path"] == "/tmp/notes.md" + assert "body" not in publish["with"] + + notes_path = tmp_path / "notes.md" + marker_path = tmp_path / "shell-expansion-ran" + script = generate["run"].replace( + "> /tmp/notes.md", f"> {shlex.quote(str(notes_path))}" + ) + custom_notes = ( + "# Literal custom notes\n" + "`printf BACKTICK_EXPANDED`\n" + f"$(touch {shlex.quote(str(marker_path))})\n" + '"double quotes" and \'single quotes\'\n' + "NOTES_EOF\n" + "- final line" + ) + env = os.environ | { + "CUSTOM_NOTES": custom_notes, + "RELEASE_VERSION": "26.08.999", + } + + subprocess.run( + ["bash", "-e", "-o", "pipefail", "-c", script], + cwd=tmp_path, + env=env, + check=True, + ) + + assert not marker_path.exists() + assert custom_notes in notes_path.read_text() + assert "\nNOTES_EOF\n" in notes_path.read_text()