378 lines
15 KiB
Bash
Executable File
378 lines
15 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
WORKFLOWS="$ROOT/.github/workflows"
|
|
|
|
fail() { echo "[FAIL] $1" >&2; exit 1; }
|
|
pass() { echo "[PASS] $1"; }
|
|
|
|
expect_file_contains() {
|
|
local file="$1"
|
|
local needle="$2"
|
|
local msg="$3"
|
|
if ! grep -Fq -- "$needle" "$file"; then
|
|
fail "$msg (missing: '$needle' in ${file#"$ROOT"/})"
|
|
fi
|
|
}
|
|
|
|
line_of() {
|
|
local file="$1"
|
|
local needle="$2"
|
|
local line
|
|
line="$(grep -nFm1 -- "$needle" "$file" || true)"
|
|
line="${line%%:*}"
|
|
if [[ -z "$line" ]]; then
|
|
fail "missing expected workflow line '$needle' in ${file#"$ROOT"/}"
|
|
fi
|
|
printf '%s\n' "$line"
|
|
}
|
|
|
|
input_section() {
|
|
local file="$1"
|
|
local input="$2"
|
|
awk -v needle=" ${input}:" '
|
|
index($0, needle) { active = 1 }
|
|
active { print }
|
|
active && index($0, "type: boolean") { exit }
|
|
' "$file"
|
|
}
|
|
|
|
workflow_section() {
|
|
local file="$1"
|
|
local start="$2"
|
|
local end="$3"
|
|
awk -v start="$start" -v end="$end" '
|
|
index($0, start) { active = 1 }
|
|
active { print }
|
|
active && index($0, end) { exit }
|
|
' "$file"
|
|
}
|
|
|
|
expect_section_contains() {
|
|
local section="$1"
|
|
local needle="$2"
|
|
local msg="$3"
|
|
if ! grep -Fq -- "$needle" <<<"$section"; then
|
|
fail "$msg (missing: '$needle')"
|
|
fi
|
|
}
|
|
|
|
assert_no_github_script_injected_binding_redeclarations() {
|
|
local workflow="$1"
|
|
awk -v file="${workflow#"$ROOT"/}" '
|
|
/uses: actions\/github-script@/ {
|
|
pending_script = 1
|
|
in_script = 0
|
|
next
|
|
}
|
|
pending_script && /^[[:space:]]*script: \|/ {
|
|
in_script = 1
|
|
next
|
|
}
|
|
in_script && /^[[:space:]]{6}- name:/ {
|
|
pending_script = 0
|
|
in_script = 0
|
|
}
|
|
in_script && /^[[:space:]]*(const|let|var)[[:space:]]+(core|github|context|glob|io|exec)[[:space:]]*=/ {
|
|
printf("[FAIL] %s redeclares actions/github-script injected binding at line %d: %s\n", file, NR, $0) > "/dev/stderr"
|
|
exit 1
|
|
}
|
|
' "$workflow"
|
|
}
|
|
|
|
test_maven_workflow_jobs_setup_jdk25_before_maven() {
|
|
echo "Running test_maven_workflow_jobs_setup_jdk25_before_maven"
|
|
|
|
local workflow
|
|
for workflow in "$WORKFLOWS"/*.yml; do
|
|
if ! grep -Eq '(^|[[:space:]"({;])xvfb-run[[:space:]]+mvn[[:space:]-]|(^|[[:space:]"({;])mvn[[:space:]-]' "$workflow"; then
|
|
continue
|
|
fi
|
|
|
|
awk -v file="${workflow#"$ROOT"/}" '
|
|
/^jobs:/ { in_jobs = 1; next }
|
|
in_jobs && /^ [A-Za-z0-9_-]+:$/ {
|
|
job = $1
|
|
sub(/:$/, "", job)
|
|
setup = 0
|
|
temurin = 0
|
|
jdk25 = 0
|
|
next
|
|
}
|
|
/^[^[:space:]]/ && $0 !~ /^jobs:/ {
|
|
in_jobs = 0
|
|
}
|
|
/uses: actions\/setup-java@v5/ {
|
|
setup = 1
|
|
temurin = 0
|
|
jdk25 = 0
|
|
}
|
|
setup && /distribution: temurin/ { temurin = 1 }
|
|
setup && /java-version: 25/ { jdk25 = 1 }
|
|
/^[[:space:]]*#/ { next }
|
|
/(^|[[:space:]"({;])xvfb-run[[:space:]]+mvn[[:space:]-]|(^|[[:space:]"({;])mvn[[:space:]-]/ {
|
|
if (!setup || !temurin || !jdk25) {
|
|
printf("[FAIL] %s job %s runs Maven before Temurin JDK 25 setup at line %d\n", file, job ? job : "(unknown)", NR) > "/dev/stderr"
|
|
exit 1
|
|
}
|
|
}
|
|
' "$workflow"
|
|
done
|
|
|
|
pass "test_maven_workflow_jobs_setup_jdk25_before_maven"
|
|
}
|
|
|
|
test_mutating_manual_workflows_default_to_dry_run() {
|
|
echo "Running test_mutating_manual_workflows_default_to_dry_run"
|
|
|
|
local workflow
|
|
for workflow in \
|
|
release-scheduler.yml \
|
|
prepare-release.yml \
|
|
publish-release.yml \
|
|
github-release.yml \
|
|
snapshot.yml \
|
|
release-health.yml; do
|
|
local file="$WORKFLOWS/$workflow"
|
|
local section
|
|
section="$(input_section "$file" dryRun)"
|
|
expect_section_contains "$section" "default: true" "${workflow} manual dryRun input should default true"
|
|
expect_section_contains "$section" "type: boolean" "${workflow} manual dryRun input should be typed as boolean"
|
|
done
|
|
|
|
pass "test_mutating_manual_workflows_default_to_dry_run"
|
|
}
|
|
|
|
test_official_triggers_normalize_to_non_dry_run() {
|
|
echo "Running test_official_triggers_normalize_to_non_dry_run"
|
|
|
|
expect_file_contains "$WORKFLOWS/release-scheduler.yml" 'if [ "$event" = "workflow_dispatch" ]; then' \
|
|
"release scheduler should branch normalization by event"
|
|
expect_file_contains "$WORKFLOWS/release-scheduler.yml" "raw=\"\${DRY_RUN_INPUT:-true}\"" \
|
|
"release scheduler manual default should normalize to dry-run"
|
|
expect_file_contains "$WORKFLOWS/release-scheduler.yml" "dry_run=false" \
|
|
"release scheduler scheduled runs should normalize to non-dry-run"
|
|
|
|
expect_file_contains "$WORKFLOWS/publish-release.yml" 'if [ "${{ github.event_name }}" = "pull_request" ]; then' \
|
|
"publish-release should normalize merged release PRs to non-dry-run"
|
|
expect_file_contains "$WORKFLOWS/github-release.yml" 'if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then' \
|
|
"github-release should keep tag pushes non-dry-run"
|
|
expect_file_contains "$WORKFLOWS/snapshot.yml" 'if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then' \
|
|
"snapshot should keep master pushes non-dry-run"
|
|
expect_file_contains "$WORKFLOWS/release-health.yml" 'if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then' \
|
|
"release-health should keep schedule/push/workflow-run triggers non-dry-run"
|
|
|
|
pass "test_official_triggers_normalize_to_non_dry_run"
|
|
}
|
|
|
|
test_downstream_dispatches_explicitly_pass_dry_run() {
|
|
echo "Running test_downstream_dispatches_explicitly_pass_dry_run"
|
|
|
|
local scheduler_dispatch
|
|
scheduler_dispatch="$(workflow_section "$WORKFLOWS/release-scheduler.yml" 'workflow_id: "prepare-release.yml"' "dryRun")"
|
|
expect_section_contains "$scheduler_dispatch" "dryRun" \
|
|
"release scheduler dispatch should pass dryRun to prepare-release"
|
|
|
|
local prepare_dispatch
|
|
prepare_dispatch="$(workflow_section "$WORKFLOWS/prepare-release.yml" 'workflow_id: "publish-release.yml"' "dryRun")"
|
|
expect_section_contains "$prepare_dispatch" "dryRun" \
|
|
"prepare-release dispatch should pass dryRun to publish-release"
|
|
|
|
local publish_dispatch
|
|
publish_dispatch="$(workflow_section "$WORKFLOWS/publish-release.yml" 'workflow_id: "snapshot.yml"' 'dryRun: "false"')"
|
|
expect_section_contains "$publish_dispatch" 'dryRun: "false"' \
|
|
"publish-release should explicitly dispatch snapshot publication as non-dry-run"
|
|
|
|
pass "test_downstream_dispatches_explicitly_pass_dry_run"
|
|
}
|
|
|
|
test_mutating_steps_remain_dry_run_gated() {
|
|
echo "Running test_mutating_steps_remain_dry_run_gated"
|
|
|
|
expect_file_contains "$WORKFLOWS/release-scheduler.yml" "if: always() && needs.analyze.result != 'skipped' && needs.analyze.outputs.dryRun != 'true'" \
|
|
"release scheduler discussion mutation should skip dry-run and skipped scheduled runs"
|
|
|
|
expect_file_contains "$WORKFLOWS/prepare-release.yml" "if: steps.dry_run.outputs.dryRun != 'true'" \
|
|
"prepare-release mutations should be dry-run gated"
|
|
expect_file_contains "$WORKFLOWS/prepare-release.yml" "Create or update removal-ready deprecation issues" \
|
|
"prepare-release should still have managed issue sync"
|
|
expect_file_contains "$WORKFLOWS/prepare-release.yml" "Create or update release PR" \
|
|
"prepare-release should still have release PR mutation"
|
|
|
|
expect_file_contains "$WORKFLOWS/publish-release.yml" "Deploy Release to Maven Central" \
|
|
"publish-release should still have Maven Central deployment"
|
|
expect_file_contains "$WORKFLOWS/publish-release.yml" "if: steps.dry_run.outputs.dryRun != 'true'" \
|
|
"publish-release mutations should be dry-run gated"
|
|
expect_file_contains "$WORKFLOWS/publish-release.yml" "if: always() && steps.dry_run.outputs.dryRun != 'true'" \
|
|
"publish-release discussion mutation should skip dry-run"
|
|
|
|
expect_file_contains "$WORKFLOWS/github-release.yml" "Create GitHub Release" \
|
|
"github-release should still publish GitHub releases"
|
|
expect_file_contains "$WORKFLOWS/github-release.yml" "if: steps.dry_run.outputs.dryRun != 'true'" \
|
|
"github-release publication should skip dry-run"
|
|
|
|
expect_file_contains "$WORKFLOWS/snapshot.yml" "Deploy Snapshot" \
|
|
"snapshot workflow should still publish snapshots"
|
|
expect_file_contains "$WORKFLOWS/snapshot.yml" "if: steps.dry_run.outputs.dryRun != 'true'" \
|
|
"snapshot deployment and publishing-secret steps should skip dry-run"
|
|
|
|
expect_file_contains "$WORKFLOWS/release-health.yml" "if: always() && steps.dry_run.outputs.dryRun != 'true'" \
|
|
"release-health discussion mutation should skip dry-run"
|
|
|
|
pass "test_mutating_steps_remain_dry_run_gated"
|
|
}
|
|
|
|
test_dry_run_summaries_and_audits_show_rerun_guidance() {
|
|
echo "Running test_dry_run_summaries_and_audits_show_rerun_guidance"
|
|
|
|
expect_file_contains "$WORKFLOWS/release-scheduler.yml" "release-scheduler-mutation-plan.txt" \
|
|
"release scheduler audit artifact should include the dry-run mutation plan"
|
|
expect_file_contains "$WORKFLOWS/prepare-release.yml" '"mutationPlan"' \
|
|
"prepare-release audit JSON should include mutation plan"
|
|
expect_file_contains "$WORKFLOWS/publish-release.yml" '"mutationPlan"' \
|
|
"publish-release audit JSON should include mutation plan"
|
|
expect_file_contains "$WORKFLOWS/github-release.yml" '"mutationPlan"' \
|
|
"github-release audit JSON should include mutation plan"
|
|
expect_file_contains "$WORKFLOWS/snapshot.yml" '"snapshotVersion"' \
|
|
"snapshot audit JSON should include computed snapshot version"
|
|
expect_file_contains "$WORKFLOWS/snapshot.yml" '"mutationPlan"' \
|
|
"snapshot audit JSON should include mutation plan"
|
|
expect_file_contains "$WORKFLOWS/release-health.yml" '"mutationPlan"' \
|
|
"release-health audit JSON should include mutation plan"
|
|
expect_file_contains "$WORKFLOWS/prepare-release.yml" "rerun guidance" \
|
|
"prepare-release summary should include rerun guidance"
|
|
expect_file_contains "$WORKFLOWS/publish-release.yml" "rerun guidance" \
|
|
"publish-release summary should include rerun guidance"
|
|
expect_file_contains "$WORKFLOWS/snapshot.yml" "rerun guidance" \
|
|
"snapshot summary should include rerun guidance"
|
|
|
|
pass "test_dry_run_summaries_and_audits_show_rerun_guidance"
|
|
}
|
|
|
|
test_snapshot_and_health_manual_dry_runs_do_not_mutate() {
|
|
echo "Running test_snapshot_and_health_manual_dry_runs_do_not_mutate"
|
|
|
|
local deploy_section
|
|
deploy_section="$(workflow_section "$WORKFLOWS/snapshot.yml" "Deploy Snapshot" "Snapshot publication summary")"
|
|
expect_section_contains "$deploy_section" "if: steps.dry_run.outputs.dryRun != 'true'" \
|
|
"snapshot deployment should skip dry-run"
|
|
|
|
local health_post_section
|
|
health_post_section="$(workflow_section "$WORKFLOWS/release-health.yml" "Post to Release Scheduler discussion" "with:")"
|
|
expect_section_contains "$health_post_section" "if: always() && steps.dry_run.outputs.dryRun != 'true'" \
|
|
"release-health discussion post should skip dry-run"
|
|
|
|
pass "test_snapshot_and_health_manual_dry_runs_do_not_mutate"
|
|
}
|
|
|
|
test_line_of_reports_missing_needles_cleanly() {
|
|
echo "Running test_line_of_reports_missing_needles_cleanly"
|
|
|
|
local tmp
|
|
local output
|
|
tmp="$(mktemp "${TMPDIR:-/tmp}/release-workflow-safety.XXXXXX")"
|
|
printf 'present\n' > "$tmp"
|
|
|
|
if output="$( (line_of "$tmp" "missing") 2>&1 )"; then
|
|
rm -f "$tmp"
|
|
fail "line_of should fail when the workflow line is missing"
|
|
fi
|
|
|
|
rm -f "$tmp"
|
|
expect_section_contains "$output" "missing expected workflow line 'missing'" \
|
|
"line_of should surface the explicit missing-line failure message"
|
|
|
|
pass "test_line_of_reports_missing_needles_cleanly"
|
|
}
|
|
|
|
test_publish_release_existing_tag_only_fails_real_runs() {
|
|
echo "Running test_publish_release_existing_tag_only_fails_real_runs"
|
|
|
|
expect_file_contains "$WORKFLOWS/publish-release.yml" \
|
|
"if: steps.check_tag.outputs.exists == 'true' && steps.dry_run.outputs.dryRun != 'true'" \
|
|
"publish-release should fail existing tags only during real runs"
|
|
expect_file_contains "$WORKFLOWS/publish-release.yml" "Report existing tag in dry-run mode" \
|
|
"publish-release should keep a dry-run audit path for existing tags"
|
|
expect_file_contains "$WORKFLOWS/publish-release.yml" "audit:existing_tag_dry_run" \
|
|
"publish-release dry-run tag detection should emit an audit line"
|
|
|
|
pass "test_publish_release_existing_tag_only_fails_real_runs"
|
|
}
|
|
|
|
test_github_release_preserves_workflow_support_checkout() {
|
|
echo "Running test_github_release_preserves_workflow_support_checkout"
|
|
|
|
local full_checkout_line
|
|
local support_checkout_line
|
|
local manifest_line
|
|
full_checkout_line="$(line_of "$WORKFLOWS/github-release.yml" "Checkout full history")"
|
|
support_checkout_line="$(line_of "$WORKFLOWS/github-release.yml" "Checkout workflow support files")"
|
|
manifest_line="$(line_of "$WORKFLOWS/github-release.yml" "workflow-support/scripts/release/release_helpers.py artifact-manifest")"
|
|
|
|
if (( support_checkout_line <= full_checkout_line )); then
|
|
fail "github-release should checkout workflow support after the release tag checkout"
|
|
fi
|
|
if (( manifest_line <= support_checkout_line )); then
|
|
fail "github-release should validate artifacts after workflow support checkout"
|
|
fi
|
|
expect_file_contains "$WORKFLOWS/github-release.yml" "path: workflow-support" \
|
|
"github-release should keep support helpers outside the release tag checkout"
|
|
|
|
pass "test_github_release_preserves_workflow_support_checkout"
|
|
}
|
|
|
|
test_github_script_blocks_do_not_redeclare_injected_bindings() {
|
|
echo "Running test_github_script_blocks_do_not_redeclare_injected_bindings"
|
|
|
|
local workflow
|
|
for workflow in "$WORKFLOWS"/*.yml; do
|
|
assert_no_github_script_injected_binding_redeclarations "$workflow"
|
|
done
|
|
|
|
pass "test_github_script_blocks_do_not_redeclare_injected_bindings"
|
|
}
|
|
|
|
test_github_script_binding_scan_rejects_bad_fixture() {
|
|
echo "Running test_github_script_binding_scan_rejects_bad_fixture"
|
|
|
|
local tmp
|
|
local output
|
|
tmp="$(mktemp "${TMPDIR:-/tmp}/release-workflow-safety.XXXXXX")"
|
|
cat > "$tmp" <<'YAML'
|
|
name: Bad fixture
|
|
jobs:
|
|
bad:
|
|
steps:
|
|
- name: Bad GitHub Script
|
|
uses: actions/github-script@v9
|
|
with:
|
|
script: |
|
|
const core = require("@actions/core");
|
|
YAML
|
|
|
|
if output="$(assert_no_github_script_injected_binding_redeclarations "$tmp" 2>&1)"; then
|
|
rm -f "$tmp"
|
|
fail "github-script injected binding scan should fail on a core redeclaration"
|
|
fi
|
|
|
|
rm -f "$tmp"
|
|
expect_section_contains "$output" "redeclares actions/github-script injected binding" \
|
|
"github-script injected binding scan should report the redeclaration"
|
|
|
|
pass "test_github_script_binding_scan_rejects_bad_fixture"
|
|
}
|
|
|
|
test_maven_workflow_jobs_setup_jdk25_before_maven
|
|
test_mutating_manual_workflows_default_to_dry_run
|
|
test_official_triggers_normalize_to_non_dry_run
|
|
test_downstream_dispatches_explicitly_pass_dry_run
|
|
test_mutating_steps_remain_dry_run_gated
|
|
test_dry_run_summaries_and_audits_show_rerun_guidance
|
|
test_snapshot_and_health_manual_dry_runs_do_not_mutate
|
|
test_line_of_reports_missing_needles_cleanly
|
|
test_publish_release_existing_tag_only_fails_real_runs
|
|
test_github_release_preserves_workflow_support_checkout
|
|
test_github_script_blocks_do_not_redeclare_injected_bindings
|
|
test_github_script_binding_scan_rejects_bad_fixture
|