From 6b447e9708665085864978180221ba3d899e5001 Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:56:08 -0400 Subject: [PATCH] fix(ci): require explicit release publication --- .github/workflows/ci.yml | 2 + .github/workflows/release.yml | 6 +- scripts/check-release-publish-gate.sh | 162 ++++++++++++++++++++++++++ 3 files changed, 168 insertions(+), 2 deletions(-) create mode 100755 scripts/check-release-publish-gate.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 85eef99dbb..68abfb055c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,8 @@ jobs: fetch-depth: 0 - name: Guard - lint baseline may only shrink run: bash ./scripts/check-lint-baseline.sh + - name: Guard - release publication requires explicit opt-in + run: bash scripts/check-release-publish-gate.sh --self-test test: name: Unit Tests runs-on: namespace-profile-nekoyay diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b44ed23065..3b8f64bcfb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,8 +6,10 @@ on: description: "Release Tag" required: true publish: - description: "Publish: If want ignore" + description: "Publish the completed release" required: false + type: boolean + default: false env: MIERU_VERSION: v3.34.1 MDVPN_REF: android-vpnservice-protect-hook @@ -159,7 +161,7 @@ jobs: path: ${{ env.APK }} publish: name: Publish Release - if: github.event.inputs.publish != 'y' + if: ${{ inputs.publish == true }} runs-on: namespace-profile-nekoyay permissions: contents: write diff --git a/scripts/check-release-publish-gate.sh b/scripts/check-release-publish-gate.sh new file mode 100755 index 0000000000..33deef7629 --- /dev/null +++ b/scripts/check-release-publish-gate.sh @@ -0,0 +1,162 @@ +#!/usr/bin/env bash +set -euo pipefail + +WORKFLOW=".github/workflows/release.yml" + +has_safe_publish_input() { + local file=$1 + local block line + local has_type=false + local has_default=false + + block=$( + awk ' + /^ workflow_dispatch:[[:space:]]*$/ { in_dispatch = 1; next } + in_dispatch && /^ inputs:[[:space:]]*$/ { in_inputs = 1; next } + in_inputs && /^ publish:[[:space:]]*$/ { in_publish = 1; next } + in_publish && $0 !~ /^ / && $0 !~ /^[[:space:]]*$/ { exit } + in_publish { print } + ' "$file" + ) + + while IFS= read -r line; do + [[ "$line" =~ ^[[:space:]]+type:[[:space:]]*boolean[[:space:]]*$ ]] && has_type=true + [[ "$line" =~ ^[[:space:]]+default:[[:space:]]*false[[:space:]]*$ ]] && has_default=true + done <<< "$block" + + $has_type && $has_default +} + +publish_job_block() { + local file=$1 + awk ' + /^ publish:[[:space:]]*$/ { in_publish = 1; next } + in_publish && $0 !~ /^ / && $0 !~ /^[[:space:]]*$/ { exit } + in_publish { print } + ' "$file" +} + +has_positive_publish_condition() { + local block=$1 + local line + while IFS= read -r line; do + if [[ "$line" =~ ^[[:space:]]+if:[[:space:]]*\$\{\{[[:space:]]*inputs\.publish[[:space:]]*==[[:space:]]*true[[:space:]]*\}\}[[:space:]]*$ ]]; then + return 0 + fi + done <<< "$block" + return 1 +} + +has_publish_write_permission() { + local block=$1 + local line + local in_permissions=false + while IFS= read -r line; do + if [[ "$line" =~ ^[[:space:]]{4}permissions:[[:space:]]*$ ]]; then + in_permissions=true + continue + fi + if $in_permissions && [[ "$line" =~ ^[[:space:]]{4}[^[:space:]] ]]; then + break + fi + if $in_permissions && [[ "$line" =~ ^[[:space:]]+contents:[[:space:]]*write[[:space:]]*$ ]]; then + return 0 + fi + done <<< "$block" + return 1 +} + +has_workflow_read_permission() { + local file=$1 + local line + local in_permissions=false + while IFS= read -r line; do + if [[ "$line" =~ ^permissions:[[:space:]]*$ ]]; then + in_permissions=true + continue + fi + if $in_permissions && [[ "$line" =~ ^[^[:space:]] ]]; then + break + fi + if $in_permissions && [[ "$line" =~ ^[[:space:]]+contents:[[:space:]]*read[[:space:]]*$ ]]; then + return 0 + fi + done < "$file" + return 1 +} + +check_file() { + local file=$1 + local failed=0 + local content + local publish_block + content=$(<"$file") + publish_block=$(publish_job_block "$file") + + if ! has_safe_publish_input "$file"; then + echo "Error: release publish input must be a boolean that defaults to false: $file" >&2 + failed=1 + fi + + if ! has_positive_publish_condition "$publish_block"; then + echo "Error: publish job must use the positive inputs.publish == true condition: $file" >&2 + failed=1 + fi + + if [[ "$content" == *"github.event.inputs.publish != 'y'"* ]]; then + echo "Error: legacy fail-open release publish condition is present: $file" >&2 + failed=1 + fi + + if ! has_workflow_read_permission "$file"; then + echo "Error: workflow-level contents permission must remain read-only: $file" >&2 + failed=1 + fi + + if ! has_publish_write_permission "$publish_block"; then + echo "Error: publish job must retain contents write permission: $file" >&2 + failed=1 + fi + + return "$failed" +} + +self_test() { + check_file "$WORKFLOW" + + local tmp_dir + local unsafe_workflow + local quoted_tmp_dir + tmp_dir=$(mktemp -d) + unsafe_workflow="$tmp_dir/release.yml" + printf -v quoted_tmp_dir '%q' "$tmp_dir" + trap "rm -rf -- $quoted_tmp_dir" EXIT + + cp "$WORKFLOW" "$unsafe_workflow" + sed 's#if: \${{ inputs\.publish == true }}#if: github.event.inputs.publish != '"'"'y'"'"'#' \ + "$unsafe_workflow" > "$unsafe_workflow.tmp" + mv "$unsafe_workflow.tmp" "$unsafe_workflow" + + if check_file "$unsafe_workflow"; then + echo "Error: release publish guard accepted the prior unsafe condition" >&2 + return 1 + fi + + rm -rf -- "$tmp_dir" + trap - EXIT + echo "OK: release publish gate is explicit and rejects the prior unsafe condition." +} + +case "${1:-}" in + "") + check_file "$WORKFLOW" + echo "OK: release publish gate is explicit and safe by default." + ;; + --self-test) + self_test + ;; + *) + echo "Usage: $0 [--self-test]" >&2 + exit 2 + ;; +esac