Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions .github/tests/test-upsert-issue-ci.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
# Keep issue lifecycle self-tests offline and without a live write credential.
set -euo pipefail

root="$(cd "$(dirname "$0")/../.." && pwd)"
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
yq -o=json '.' "${1:-$root/.github/workflows/ci.yaml}" >"$work/ci.json"
job_condition="\${{ github.event_name != 'merge_group' && !startsWith(github.event.head_commit.message, 'chore(main): release ') }}"

guard() {
jq -e --arg condition "$job_condition" '
def upsert_path:
(split("@")[0] // "") | split("/") | reduce .[] as $part ([];
if $part == "" or $part == "." then .
elif $part == ".." then .[0:-1] else . + [$part] end) |
last == "upsert-issue";
. as $workflow |
if [.jobs | to_entries[] | .key as $job | .value.steps[]? |
select((.uses // "") | upsert_path) |
select($job != "test-upsert-issue" or .with["github-token"] != "offline-fixture" or
.with.repository != "offline/fixture")] | length > 0
then error("live upsert-issue invocation in catalogue CI")
elif (.jobs["test-upsert-issue"] | type) != "object"
then error("offline upsert-issue job is missing")
elif .jobs["test-upsert-issue"].permissions != {"contents":"read"}
then error("offline issue job must have only contents-read permission")
elif ([.env // {}, .jobs["test-upsert-issue"]] | tostring |
test("\\bsecrets\\b|GH_TOKEN|GITHUB_TOKEN|private-key"; "i"))
then error("offline issue job must not forward credentials")
elif ([.jobs["test-upsert-issue"].steps[] |
select((.uses // "") | startswith("actions/checkout@"))] |
length == 0 or any(.with["persist-credentials"] != false))
then error("offline issue checkout must disable persisted credentials")
elif (.jobs["test-upsert-issue"].if != $condition or
(.jobs["test-upsert-issue"]["continue-on-error"] // false) != false)
then error("offline issue job must run and propagate failures")
elif (["test-upsert-issue-reopen.sh", "test-upsert-issue-ci.sh",
"upsert-issue-smoke.sh prepare", "upsert-issue-smoke.sh verify"] | all(. as $script |
any($workflow.jobs["test-upsert-issue"].steps[];
(.run // "" | contains("bash .github/tests/" + $script)) and
.if == null and (."continue-on-error" // false) == false))) | not
then error("offline behavior and boundary checks must run and propagate failures")
elif any(.jobs["test-upsert-issue"].steps[];
.uses == "./actions/upsert-issue" and .if == null and
(."continue-on-error" // false) == false) | not
then error("hosted offline composite-action smoke test must run")
elif (.jobs["ci-required-checks"].needs | index("test-upsert-issue")) == null
then error("offline issue job must gate required CI")
elif any(.jobs["ci-required-checks"].steps[];
(.env.JOB_RESULTS // "" | contains("needs.test-upsert-issue.result")) and
(.run // "" | contains("$JOB_RESULTS"))) | not
then error("required CI must evaluate the offline issue result")
else true end
' "$1"
}

guard "$work/ci.json" >/dev/null
[[ "${2:-}" != --guard-only ]] || exit 0

# Mutate the real CI definition; each unsafe boundary must fail for its own reason.
while IFS=$'\t' read -r label mutation diagnostic; do
jq "$mutation" "$work/ci.json" >"$work/mutated.json"
if guard "$work/mutated.json" >"$work/result" 2>&1; then
echo "FAIL: $label was accepted" >&2
exit 1
fi
grep -qF "$diagnostic" "$work/result" || {
echo "FAIL: $label failed for the wrong reason" >&2
cat "$work/result" >&2
exit 1
}
echo "PASS: rejects $label"
done <<'CASES'
write permission .jobs["test-upsert-issue"].permissions.issues="write" only contents-read
job secret .jobs["test-upsert-issue"].env.EXTRA="${{ secrets[\"TOKEN\"] }}" not forward credentials
workflow secret .env.EXTRA="${{ secrets.TOKEN }}" not forward credentials
bulk secret forwarding .jobs["test-upsert-issue"].env.EXTRA="${{ toJSON(secrets) }}" not forward credentials
literal token environment .jobs["test-upsert-issue"].env.GH_TOKEN="credential" not forward credentials
persisted checkout .jobs["test-upsert-issue"].steps |= map(if (.uses // "" | startswith("actions/checkout@")) then .with["persist-credentials"]=true else . end) disable persisted credentials
missing behavior .jobs["test-upsert-issue"].steps |= map(select((.run // "" | contains("test-upsert-issue-reopen.sh")) | not)) behavior and boundary checks
ignored failure .jobs["test-upsert-issue"].steps |= map(if (.run // "" | contains("test-upsert-issue-reopen.sh")) then ."continue-on-error"=true else . end) behavior and boundary checks
skipped job .jobs["test-upsert-issue"].if="false" job must run
unguarded merge-group execution .jobs["test-upsert-issue"].if=null job must run
unsafe merge-group disjunction .jobs["test-upsert-issue"].if="${{ true || github.event_name != 'merge_group' }}" job must run
missing required dependency .jobs["ci-required-checks"].needs |= map(select(. != "test-upsert-issue")) gate required CI
missing required verdict .jobs["ci-required-checks"].steps |= map(if .env.JOB_RESULTS then .env.JOB_RESULTS |= gsub("needs.test-upsert-issue.result"; "needs.other.result") else . end) evaluate the offline issue result
live action elsewhere .jobs.unexpected={steps:[{uses:"./actions/upsert-issue"}]} live upsert-issue invocation
live action with trailing slash .jobs.unexpected={steps:[{uses:"./actions/upsert-issue/"}]} live upsert-issue invocation
live action with dot segments .jobs.unexpected={steps:[{uses:"./actions/other/../upsert-issue/."}]} live upsert-issue invocation
live smoke token .jobs["test-upsert-issue"].steps |= map(if .uses == "./actions/upsert-issue" then .with["github-token"]="live-token" else . end) live upsert-issue invocation
live smoke repository .jobs["test-upsert-issue"].steps |= map(if .uses == "./actions/upsert-issue" then .with.repository="devantler-tech/.github" else . end) live upsert-issue invocation
missing hosted smoke .jobs["test-upsert-issue"].steps |= map(select(.uses != "./actions/upsert-issue")) hosted offline composite-action
CASES
echo "PASS: upsert-issue CI is offline, read-only and required"
97 changes: 85 additions & 12 deletions .github/tests/test-upsert-issue-reopen.sh
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
#!/usr/bin/env bash

# Exercise upsert-issue's actual run block against a stubbed `gh` to prove that a tracking issue
# which should be open is never left closed while the step reports success (#1375), and that the
# action picks the right issue when several share the title.
# Exercise the action's actual run block against an offline GitHub CLI fixture.
# Cover issue lifecycle, exact payloads and outputs, body inputs, and API failures.
#
# The action tracks a recurring failure with one issue. When the matching issue is closed, the
# step must reopen it, and a reopen that keeps failing must fail the step: otherwise the body is
Expand All @@ -15,7 +14,7 @@
set -euo pipefail

root="$(cd "$(dirname "$0")/../.." && pwd)"
action="$root/actions/upsert-issue/action.yaml"
action="${1:-$root/actions/upsert-issue/action.yaml}"
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT

Expand All @@ -31,6 +30,7 @@ cat >"$work/bin/gh" <<'STUB'
# Records every call and answers from the scenario the test configured.
set -uo pipefail
printf '%s\n' "$*" >>"$STUB_DIR/calls"
jq -cn --args '$ARGS.positional' -- "$@" >>"$STUB_DIR/argv"
case "$1 $2" in
"issue list")
[[ "${STUB_LIST_RC:-0}" -eq 0 ]] || exit "$STUB_LIST_RC"
Expand All @@ -48,10 +48,16 @@ case "$1 $2" in
;;
esac
;;
"issue view") printf '%s\n' "$STUB_LIVE_STATE" ;;
"issue edit") ;;
"issue close") ;;
"issue create") printf 'https://github.com/owner/repo/issues/99\n' ;;
"issue view")
[[ "$STUB_VIEW_RC" -eq 0 ]] || exit "$STUB_VIEW_RC"
printf '%s\n' "$STUB_LIVE_STATE"
;;
"issue edit") exit "$STUB_EDIT_RC" ;;
"issue close") exit "$STUB_CLOSE_RC" ;;
"issue create")
[[ "$STUB_CREATE_RC" -eq 0 ]] || exit "$STUB_CREATE_RC"
printf '%s\n' "$STUB_CREATED_URL"
;;
"issue reopen")
attempts=$(( $(cat "$STUB_DIR/reopens" 2>/dev/null || echo 0) + 1 ))
printf '%s\n' "$attempts" >"$STUB_DIR/reopens"
Expand All @@ -76,30 +82,73 @@ failures=0
run_case() {
local label="$1" open_input="$2" open_json="$3" closed_json="$4" live="$5" reopen_failures="$6"
local list_rc="$7" want_rc="$8" want_reopens="$9" want_closes="${10}" want_creates="${11}"
local want_issue="${12}" dir rc=0 out reopens closes creates issue problem=""
local want_issue="${12}" dir rc=0 out reopens closes creates issue url problem=""
local body="${TEST_BODY-body}" body_file="" expected_body="${TEST_BODY-body}"
local labels="${TEST_LABELS:-}" repo="${TEST_REPO:-owner/repo}" comment="${TEST_COMMENT:-closed}"
dir="$(mktemp -d "$work/case.XXXXXX")"
: >"$dir/calls"
: >"$dir/argv"
: >"$dir/output"
case "${TEST_BODY_FILE:-}" in
present)
body_file="$dir/body.md"
printf '%s\n' "${TEST_FILE_BODY-file body}" >"$body_file"
expected_body="$(cat "$body_file")"
;;
missing) body_file="$dir/missing.md" ;;
esac
out="$(
PATH="$work/bin:$PATH" \
STUB_DIR="$dir" STUB_OPEN_JSON="$open_json" STUB_CLOSED_JSON="$closed_json" \
STUB_LIVE_STATE="$live" STUB_REOPEN_FAILURES="$reopen_failures" STUB_LIST_RC="$list_rc" \
STUB_EDIT_RC="${TEST_EDIT_RC:-0}" STUB_VIEW_RC="${TEST_VIEW_RC:-0}" \
STUB_CLOSE_RC="${TEST_CLOSE_RC:-0}" STUB_CREATE_RC="${TEST_CREATE_RC:-0}" \
STUB_CREATED_URL="${TEST_CREATED_URL:-https://github.com/$repo/issues/99}" \
GITHUB_ACTION_PATH="$root/actions/upsert-issue" GITHUB_OUTPUT="$dir/output" \
RETRY_MAX_ATTEMPTS=3 RETRY_BASE_DELAY=0 RETRY_MAX_DELAY=0 \
TITLE="Tracking issue" BODY="body" BODY_FILE="" LABELS="" OPEN="$open_input" \
CLOSE_COMMENT="closed" REPO="owner/repo" GH_TOKEN="stub" \
TITLE="Tracking issue" BODY="$body" BODY_FILE="$body_file" LABELS="$labels" OPEN="$open_input" \
CLOSE_COMMENT="$comment" REPO="$repo" GH_TOKEN="offline-fixture" GITHUB_TOKEN="" \
bash "$work/upsert.sh" 2>&1
)" || rc=$?
reopens="$(grep -c '^issue reopen ' "$dir/calls" || true)"
closes="$(grep -c '^issue close ' "$dir/calls" || true)"
creates="$(grep -c '^issue create ' "$dir/calls" || true)"
issue="$(sed -n 's/^issue-number=//p' "$dir/output")"
url="$(sed -n 's/^issue-url=//p' "$dir/output")"

[[ "$rc" == "$want_rc" ]] || problem="exit ${rc}, want ${want_rc}"
[[ -n "$problem" || "$reopens" == "$want_reopens" ]] || problem="${reopens} reopen call(s), want ${want_reopens}"
[[ -n "$problem" || "$closes" == "$want_closes" ]] || problem="${closes} close call(s), want ${want_closes}"
[[ -n "$problem" || "$creates" == "$want_creates" ]] || problem="${creates} create call(s), want ${want_creates}"
[[ -n "$problem" || "$issue" == "$want_issue" ]] || problem="issue-number '${issue}', want '${want_issue}'"
if [[ -z "$problem" && -n "$want_issue" && "$url" != "https://github.com/$repo/issues/$want_issue" ]]; then
problem="issue-url does not match the selected repository and issue"
elif [[ -z "$problem" && -z "$want_issue" && -s "$dir/output" ]]; then
problem="failure or no-op published issue outputs"
fi
if [[ -z "$problem" ]] && ! jq -se --arg repo "$repo" --arg body "$expected_body" \
--arg labels "$labels" --arg comment "$comment" --arg open "$open_input" --arg issue "$want_issue" '
def flag($name): .[index($name) + 1];
all(.[]; index("--repo") != null and flag("--repo") == $repo) and
all(.[] | select(.[1] == "create" or (.[1] == "edit" and index("--body") != null));
flag("--body") == $body) and
all(.[] | select(.[1] == "create"); flag("--title") == "Tracking issue") and
all(.[] | select(.[1] == "close"); flag("--comment") == $comment) and
(if $open == "true" and $issue != "" then
any(.[]; .[1] == "create" or (.[1] == "edit" and index("--body") != null))
else true end) and
(if $open == "true" and $issue != "" and $labels != "" then
any(.[]; (index("--label") != null and flag("--label") == $labels) or
(index("--add-label") != null and flag("--add-label") == $labels))
else true end)
' "$dir/argv" >/dev/null; then
problem="CLI arguments lost the repository, body, labels or close comment"
fi
if [[ -z "$problem" && -n "${TEST_DIAGNOSTIC:-}" ]]; then
if [[ -s "$dir/calls" ]] || ! grep -qF "$TEST_DIAGNOSTIC" <<<"$out"; then
problem="invalid body input reached the API or lacked its diagnostic"
fi
fi
if [[ -z "$problem" ]] && grep -q 'unexpected call' <<<"$out"; then
problem="the stub saw an unexpected gh call"
fi
Expand Down Expand Up @@ -136,8 +185,32 @@ run_case "an already-closed issue is not closed again" false "$none" "$one" CLOS
run_case "no matching issue is a no-op when closing" false "$none" "$other" CLOSED 0 0 0 0 0 0 ""
run_case "a failed search fails the closing step too" false "$none" "$none" OPEN 0 1 1 0 0 0 ""

TEST_BODY=$'a quoted "body"\nwith a second line' TEST_LABELS="bug,automation" TEST_REPO="offline/consumer" \
run_case "create preserves payload, labels and target repository" true "$none" "$none" OPEN 0 0 0 0 0 1 99
TEST_BODY="ignored inline body" TEST_BODY_FILE=present TEST_FILE_BODY=$'file "body"\nnext line' TEST_LABELS="bug" \
run_case "body file wins when updating and adding labels" true "$one" "$none" OPEN 0 0 0 0 0 0 7
TEST_COMMENT=$'resolved "report"\nnext line' \
run_case "close preserves its comment" false "$one" "$none" OPEN 0 0 0 0 1 0 7
TEST_BODY="" TEST_DIAGNOSTIC="Either 'body' or 'body-file' input must be provided" \
run_case "missing body fails before any API call" true "$none" "$none" OPEN 0 0 1 0 0 0 ""
TEST_BODY_FILE=missing TEST_DIAGNOSTIC="'body-file' not found:" \
run_case "missing body file fails before any API call" true "$none" "$none" OPEN 0 0 1 0 0 0 ""
TEST_BODY_FILE=present TEST_FILE_BODY="" TEST_DIAGNOSTIC="Either 'body' or 'body-file' input must be provided" \
run_case "empty body file fails before any API call" true "$none" "$none" OPEN 0 0 1 0 0 0 ""
TEST_CREATE_RC=1 \
run_case "failed create is never retried or reported successful" true "$none" "$none" OPEN 0 0 1 0 0 1 ""
TEST_EDIT_RC=1 \
run_case "failed update does not publish outputs" true "$one" "$none" OPEN 0 0 1 0 0 0 ""
TEST_VIEW_RC=1 \
run_case "failed state lookup does not publish outputs" true "$one" "$none" OPEN 0 0 1 0 0 0 ""
TEST_CLOSE_RC=1 \
run_case "failed close exhausts retries without success outputs" false "$one" "$none" OPEN 0 0 1 0 3 0 ""
TEST_CREATED_URL="not-an-issue-url" \
run_case "malformed create response cannot publish outputs" true "$none" "$none" OPEN 0 0 1 0 0 1 ""
run_case "malformed search data never creates a duplicate" true "invalid" "$none" OPEN 0 0 1 0 0 0 ""

[[ "$failures" -eq 0 ]] || {
echo "FAIL: ${failures} upsert-issue case(s) failed" >&2
exit 1
}
echo "PASS: upsert-issue picks the newest match, reopens a closed one, and fails rather than duplicating"
echo "PASS: 26 offline upsert-issue cases preserve lifecycle, payloads, outputs and failure boundaries"
44 changes: 44 additions & 0 deletions .github/tests/upsert-issue-smoke.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# Prepare and verify the offline CLI used by the hosted composite-action smoke test.
set -euo pipefail
work="${RUNNER_TEMP:?}/upsert-issue-smoke"

case "${1:-}" in
prepare)
mkdir -p "$work/bin"
: >"$work/calls"
cat >"$work/bin/gh" <<'STUB'
#!/usr/bin/env bash
set -euo pipefail
work="$(cd "$(dirname "$0")/.." && pwd)"
[[ "${GH_TOKEN:-}" == offline-fixture ]] || { echo 'fixture token missing' >&2; exit 1; }
jq -cn --args '$ARGS.positional' -- "$@" >>"$work/calls"
case "$1 $2" in
"issue list") printf '[]\n' ;;
"issue create") printf 'https://github.com/offline/fixture/issues/99\n' ;;
*) echo "unexpected offline CLI command" >&2; exit 1 ;;
esac
STUB
chmod +x "$work/bin/gh"
printf '%s\n' "$work/bin" >>"${GITHUB_PATH:?}"
;;
verify)
[[ "${ISSUE_NUMBER:-}" == 99 && "${ISSUE_URL:-}" == https://github.com/offline/fixture/issues/99 ]] || {
echo "FAIL: hosted action outputs did not reach the caller" >&2
exit 1
}
jq -se '
def flag($name): .[index($name) + 1];
length == 3 and
.[0][0:2] == ["issue","list"] and (.[0] | flag("--state")) == "open" and
.[1][0:2] == ["issue","list"] and (.[1] | flag("--state")) == "closed" and
.[2][0:2] == ["issue","create"] and
all(.[]; flag("--repo") == "offline/fixture") and
(.[2] | flag("--title")) == "Offline smoke fixture" and
(.[2] | flag("--body")) == "Offline smoke body" and
(.[2] | flag("--label")) == "bug"
' "$work/calls" >/dev/null
echo "PASS: hosted upsert-issue uses the offline CLI and returns exact outputs"
;;
*) echo "usage: upsert-issue-smoke.sh <prepare|verify>" >&2; exit 2 ;;
esac
Loading
Loading