diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index d08c163e..6fbd17dc 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -48,6 +48,19 @@ jobs: - name: cargo clippy run: cargo clippy --workspace --all-targets -- -D warnings + release-tag-validation: + name: release tag validation + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Check out repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Test release tag resolver + run: scripts/test-resolve-release-tag.sh + test: name: test (${{ matrix.toolchain }}) runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dbc7f852..0d8293e4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -78,6 +78,11 @@ jobs: contents: read packages: write steps: + - name: Check out workflow scripts + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + - name: Resolve release tag id: rel env: @@ -85,13 +90,7 @@ jobs: RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }} run: | set -euo pipefail - TAG="${DISPATCH_TAG:-$RELEASE_TAG}" - case "$TAG" in - v[0-9]*) ;; - *) echo "::error::'$TAG' does not look like a release tag (vX.Y.Z)"; exit 1 ;; - esac - echo "tag=$TAG" >> "$GITHUB_OUTPUT" - echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" + scripts/resolve-release-tag.sh "${DISPATCH_TAG:-$RELEASE_TAG}" # ghcr requires a lowercase repository path, and unlike metadata-action, # buildx's `--output name=` does no lowercasing — a mixed-case owner # makes the digest push fail with "invalid reference format". @@ -173,8 +172,14 @@ jobs: if: ${{ !cancelled() && needs.docker.result == 'success' }} runs-on: ubuntu-latest permissions: + contents: read packages: write steps: + - name: Check out workflow scripts + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + - name: Resolve release tag id: rel env: @@ -182,13 +187,7 @@ jobs: RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }} run: | set -euo pipefail - TAG="${DISPATCH_TAG:-$RELEASE_TAG}" - case "$TAG" in - v[0-9]*) ;; - *) echo "::error::'$TAG' does not look like a release tag (vX.Y.Z)"; exit 1 ;; - esac - echo "tag=$TAG" >> "$GITHUB_OUTPUT" - echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" + scripts/resolve-release-tag.sh "${DISPATCH_TAG:-$RELEASE_TAG}" - name: Download digests uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 @@ -404,6 +403,11 @@ jobs: contents: read id-token: write # npm trusted publishing (OIDC) + provenance steps: + - name: Check out workflow scripts + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + - name: Resolve release tag id: rel env: @@ -411,13 +415,7 @@ jobs: RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }} run: | set -euo pipefail - TAG="${DISPATCH_TAG:-$RELEASE_TAG}" - case "$TAG" in - v[0-9]*) ;; - *) echo "::error::'$TAG' does not look like a release tag (vX.Y.Z)"; exit 1 ;; - esac - echo "tag=$TAG" >> "$GITHUB_OUTPUT" - echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" + scripts/resolve-release-tag.sh "${DISPATCH_TAG:-$RELEASE_TAG}" - name: Checkout release tag uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 diff --git a/scripts/resolve-release-tag.sh b/scripts/resolve-release-tag.sh new file mode 100755 index 00000000..3d321da5 --- /dev/null +++ b/scripts/resolve-release-tag.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +tag="${1-}" +: "${GITHUB_OUTPUT:?GITHUB_OUTPUT must be set}" + +case "$tag" in + ""|*[!A-Za-z0-9._-]*) + printf '%s\n' "::error::release tag is empty or contains invalid characters" + exit 1 + ;; +esac + +case "$tag" in + v[0-9]*) ;; + *) + printf '%s\n' "::error::'$tag' does not look like a release tag (vX.Y.Z)" + exit 1 + ;; +esac + +printf '%s\n' "tag=$tag" >> "$GITHUB_OUTPUT" +printf '%s\n' "version=${tag#v}" >> "$GITHUB_OUTPUT" diff --git a/scripts/test-resolve-release-tag.sh b/scripts/test-resolve-release-tag.sh new file mode 100755 index 00000000..fc8b34ba --- /dev/null +++ b/scripts/test-resolve-release-tag.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +resolver="$repo_root/scripts/resolve-release-tag.sh" +test_tmp="$(mktemp -d)" +trap 'rm -r -- "$test_tmp"' EXIT + +valid_output="$test_tmp/valid-output" +GITHUB_OUTPUT="$valid_output" "$resolver" "v1.2.3" + +expected_output="$test_tmp/expected-output" +printf '%s\n' "tag=v1.2.3" "version=1.2.3" > "$expected_output" +cmp "$expected_output" "$valid_output" + +newline_output="$test_tmp/newline-output" +newline_stdout="$test_tmp/newline-stdout" +newline_stderr="$test_tmp/newline-stderr" +if GITHUB_OUTPUT="$newline_output" "$resolver" $'v1.2.3\nname=owned' \ + > "$newline_stdout" 2> "$newline_stderr" +then + printf '%s\n' "newline-containing release tag unexpectedly passed" >&2 + exit 1 +fi +test ! -s "$newline_output" +grep -qxF "::error::release tag is empty or contains invalid characters" "$newline_stdout" +test ! -s "$newline_stderr" + +empty_output="$test_tmp/empty-output" +if GITHUB_OUTPUT="$empty_output" "$resolver" ""; then + printf '%s\n' "empty release tag unexpectedly passed" >&2 + exit 1 +fi +test ! -s "$empty_output" + +invalid_output="$test_tmp/invalid-output" +for invalid_tag in \ + "v1.2.3 tag" \ + "v1.2.3;name=owned" \ + "v1.2.3+build.5" \ + "release-1.2.3" \ + "v-1" \ + "vlatest" \ + "v" \ + "v1.2.3/../../x" \ + "V1.2.3" +do + : > "$invalid_output" + if GITHUB_OUTPUT="$invalid_output" "$resolver" "$invalid_tag"; then + printf '%s\n' "invalid release tag unexpectedly passed: $invalid_tag" >&2 + exit 1 + fi + test ! -s "$invalid_output" +done + +release_workflow="$repo_root/.github/workflows/release.yml" +actual_resolver_steps="$test_tmp/actual-resolver-steps" +expected_resolver_steps="$test_tmp/expected-resolver-steps" + +# Pin each resolver step and the checkout step that supplies its script. Any +# change to one of these reviewed blocks must be reflected here deliberately. +awk ' + function emit_step() { + if (in_step && (is_rel || is_workflow_scripts_checkout)) { + printf "job=%s\n%s", job, step + } + in_step = 0 + is_rel = 0 + is_workflow_scripts_checkout = 0 + step = "" + } + + /^ [A-Za-z0-9_-]+:[[:space:]]*$/ { + emit_step() + job = $0 + sub(/^ /, "", job) + sub(/:[[:space:]]*$/, "", job) + next + } + + /^ - / { + emit_step() + in_step = 1 + is_workflow_scripts_checkout = ($0 ~ /^ - name:[[:space:]]*Check out workflow scripts[[:space:]]*$/) + step = $0 ORS + next + } + + in_step { + step = step $0 ORS + if ($0 ~ /^ id:[[:space:]]*rel[[:space:]]*$/) { + is_rel = 1 + } + } + + END { + emit_step() + } +' "$release_workflow" > "$actual_resolver_steps" + +cat > "$expected_resolver_steps" <<'EOF' +job=docker + - name: Check out workflow scripts + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + +job=docker + - name: Resolve release tag + id: rel + env: + DISPATCH_TAG: ${{ inputs.docker_backfill_tag }} + RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }} + run: | + set -euo pipefail + scripts/resolve-release-tag.sh "${DISPATCH_TAG:-$RELEASE_TAG}" + # ghcr requires a lowercase repository path, and unlike metadata-action, + # buildx's `--output name=` does no lowercasing — a mixed-case owner + # makes the digest push fail with "invalid reference format". + echo "image=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT" + +job=docker-manifest + - name: Check out workflow scripts + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + +job=docker-manifest + - name: Resolve release tag + id: rel + env: + DISPATCH_TAG: ${{ inputs.docker_backfill_tag }} + RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }} + run: | + set -euo pipefail + scripts/resolve-release-tag.sh "${DISPATCH_TAG:-$RELEASE_TAG}" + +job=npm-publish + - name: Check out workflow scripts + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + +job=npm-publish + - name: Resolve release tag + id: rel + env: + DISPATCH_TAG: ${{ inputs.npm_backfill_tag }} + RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }} + run: | + set -euo pipefail + scripts/resolve-release-tag.sh "${DISPATCH_TAG:-$RELEASE_TAG}" + +EOF + +if ! cmp "$expected_resolver_steps" "$actual_resolver_steps"; then + printf '%s\n' \ + "release workflow resolver steps differ from the three reviewed blocks" >&2 + diff -u "$expected_resolver_steps" "$actual_resolver_steps" >&2 || true + exit 1 +fi + +printf '%s\n' "release tag validation tests passed"