From 7f852183e75155ead6ff527da4281fe15a138ea2 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Wed, 15 Apr 2026 00:24:55 +0200 Subject: [PATCH 1/5] stash/restore: add retry-count and fail-on-download inputs Add retry-count input (default 3) that retries `gh run download` when it exits with code 1, and fail-on-download input (default false) that makes the step fail when the download did not succeed. Also disable errexit in the Download Stash script so a single failing command cannot abort the step, and fix the previous `|| download=failed && download=success` one-liner that always reported success regardless of the download result. --- stash/restore/action.yml | 59 +++++++++++++++++++++++++++++++++++----- 1 file changed, 52 insertions(+), 7 deletions(-) diff --git a/stash/restore/action.yml b/stash/restore/action.yml index 50b43f1d..73bfc5c3 100644 --- a/stash/restore/action.yml +++ b/stash/restore/action.yml @@ -45,6 +45,20 @@ inputs: If true, only the current branch will be searched for the stash. If false, the base branch(PRs)/default branch will be searched as well. default: "false" + retry-count: + description: > + Number of attempts for downloading the stash artifact. When `gh run download` + exits with code 1 (the transient failure mode observed for artifact downloads), + the download will be retried until it succeeds or this many attempts have been + made. Other exit codes are not retried. + default: "3" + fail-on-download: + description: > + If true, the action will fail when a stash artifact was found but could not be + downloaded (after exhausting `retry-count` attempts). If false (the default), + a failed download is reported only via the `stash-hit` output and the step + itself succeeds. + default: "false" outputs: stash-hit: description: > @@ -148,22 +162,53 @@ runs: STASH_RUN_ID: "${{ steps.check-stash.outputs.stash_run_id }}" REPO: "${{ github.repository }}" STASH_DIR: "${{ steps.mung.outputs.stash_path }}" - INPUTS_CLEAN: ${{ inputs.clean }} + INPUTS_CLEAN: "${{ inputs.clean }}" + RETRY_COUNT: "${{ inputs.retry-count }}" + FAIL_ON_DOWNLOAD: "${{ inputs.fail-on-download }}" run: | - # Catch errors in the download with || to avoid the whole workflow failing - # when the download times out + # The default GitHub Actions bash shell runs with `set -eo pipefail`, + # which would abort this step the moment `gh run download` or `rm -rf` + # returns non-zero. Disable `errexit` explicitly so a single failing + # command cannot kill the step — we handle failures ourselves via $?. + set +e if [[ "${INPUTS_CLEAN}" == "true" ]]; then if [[ -d "$STASH_DIR" ]]; then echo "Removing existing stash directory: $STASH_DIR" rm -rf "$STASH_DIR" fi fi - gh run download "$STASH_RUN_ID" \ - --name "$STASH_NAME" \ - --dir "$STASH_DIR" \ - -R "$REPO" || download="failed" && download="success" + # Retry up to RETRY_COUNT times when `gh run download` exits with + # code 1 (the transient failure mode observed for artifact downloads). + download="failed" + attempt=1 + while (( attempt <= RETRY_COUNT )); do + echo "Downloading stash (attempt $attempt of $RETRY_COUNT)..." + gh run download "$STASH_RUN_ID" \ + --name "$STASH_NAME" \ + --dir "$STASH_DIR" \ + -R "$REPO" + rc=$? + if (( rc == 0 )); then + download="success" + break + fi + if (( rc != 1 )); then + echo "::warning ::gh run download failed with exit code $rc; not retrying." + break + fi + echo "::warning ::gh run download failed with exit code 1 on attempt $attempt." + attempt=$(( attempt + 1 )) + done echo "download=$download" >> "$GITHUB_OUTPUT" + if [[ "$download" != "success" && "$FAIL_ON_DOWNLOAD" == "true" ]]; then + echo "::error ::Stash artifact download failed after $RETRY_COUNT attempt(s) and fail-on-download is true." + exit 1 + fi + # Otherwise exit 0 — the `download` output tells downstream steps + # whether the stash was restored, and a failed download must not fail + # the step unless the caller opted in via fail-on-download. + exit 0 - name: Set stash-hit Output id: output From 05dc3b7833af647bb361c97a3e42788e2b6558ef Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Wed, 15 Apr 2026 00:29:39 +0200 Subject: [PATCH 2/5] stash/restore: extract download script and add bash unit tests Move the retry/fail-on-download logic out of action.yml into a standalone download_stash.sh script so it can be exercised in isolation, and add test_download_stash.sh which covers: - success on first attempt - retry on exit code 1 until success - all retries exhausted with fail-on-download=false (tolerated) - all retries exhausted with fail-on-download=true (step fails) - non-1 exit codes are not retried - CLEAN=true removes the stash dir before download - CLEAN=false leaves the stash dir contents alone - mixed transient+fatal stops on first non-1 code The tests stub `gh` via a PATH shim so they run offline, and are wired into the existing stash-action-test workflow alongside the Python unit tests. --- .github/workflows/stash-action-test.yml | 1 + stash/restore/action.yml | 46 +----- stash/restore/download_stash.sh | 66 ++++++++ stash/restore/test_download_stash.sh | 194 ++++++++++++++++++++++++ 4 files changed, 263 insertions(+), 44 deletions(-) create mode 100755 stash/restore/download_stash.sh create mode 100755 stash/restore/test_download_stash.sh diff --git a/.github/workflows/stash-action-test.yml b/.github/workflows/stash-action-test.yml index 340cd227..2a0806b4 100644 --- a/.github/workflows/stash-action-test.yml +++ b/.github/workflows/stash-action-test.yml @@ -71,6 +71,7 @@ jobs: run: | cd stash/restore python3 test_get_stash.py + bash test_download_stash.sh - name: Test Save uses: ./stash/save diff --git a/stash/restore/action.yml b/stash/restore/action.yml index 73bfc5c3..d6337841 100644 --- a/stash/restore/action.yml +++ b/stash/restore/action.yml @@ -162,53 +162,11 @@ runs: STASH_RUN_ID: "${{ steps.check-stash.outputs.stash_run_id }}" REPO: "${{ github.repository }}" STASH_DIR: "${{ steps.mung.outputs.stash_path }}" - INPUTS_CLEAN: "${{ inputs.clean }}" + CLEAN: "${{ inputs.clean }}" RETRY_COUNT: "${{ inputs.retry-count }}" FAIL_ON_DOWNLOAD: "${{ inputs.fail-on-download }}" run: | - # The default GitHub Actions bash shell runs with `set -eo pipefail`, - # which would abort this step the moment `gh run download` or `rm -rf` - # returns non-zero. Disable `errexit` explicitly so a single failing - # command cannot kill the step — we handle failures ourselves via $?. - set +e - if [[ "${INPUTS_CLEAN}" == "true" ]]; then - if [[ -d "$STASH_DIR" ]]; then - echo "Removing existing stash directory: $STASH_DIR" - rm -rf "$STASH_DIR" - fi - fi - # Retry up to RETRY_COUNT times when `gh run download` exits with - # code 1 (the transient failure mode observed for artifact downloads). - download="failed" - attempt=1 - while (( attempt <= RETRY_COUNT )); do - echo "Downloading stash (attempt $attempt of $RETRY_COUNT)..." - gh run download "$STASH_RUN_ID" \ - --name "$STASH_NAME" \ - --dir "$STASH_DIR" \ - -R "$REPO" - rc=$? - if (( rc == 0 )); then - download="success" - break - fi - if (( rc != 1 )); then - echo "::warning ::gh run download failed with exit code $rc; not retrying." - break - fi - echo "::warning ::gh run download failed with exit code 1 on attempt $attempt." - attempt=$(( attempt + 1 )) - done - - echo "download=$download" >> "$GITHUB_OUTPUT" - if [[ "$download" != "success" && "$FAIL_ON_DOWNLOAD" == "true" ]]; then - echo "::error ::Stash artifact download failed after $RETRY_COUNT attempt(s) and fail-on-download is true." - exit 1 - fi - # Otherwise exit 0 — the `download` output tells downstream steps - # whether the stash was restored, and a failed download must not fail - # the step unless the caller opted in via fail-on-download. - exit 0 + bash "${{ github.action_path }}/download_stash.sh" - name: Set stash-hit Output id: output diff --git a/stash/restore/download_stash.sh b/stash/restore/download_stash.sh new file mode 100755 index 00000000..71262e26 --- /dev/null +++ b/stash/restore/download_stash.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Copyright (c) The stash contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Download a stash artifact with retries. +# +# Required env vars: +# STASH_RUN_ID - workflow run ID the artifact was produced by +# STASH_NAME - artifact name +# STASH_DIR - destination directory +# REPO - owner/name of the repository +# RETRY_COUNT - max download attempts (retries on gh exit code 1) +# FAIL_ON_DOWNLOAD - "true" to exit 1 on download failure, else "false" +# CLEAN - "true" to remove STASH_DIR before downloading +# GITHUB_OUTPUT - file to write the `download` output to + +# Disable errexit explicitly so a single failing command (gh run download, +# rm -rf) cannot abort the step — failures are handled via $? below. +set +e + +if [[ "${CLEAN}" == "true" ]]; then + if [[ -d "$STASH_DIR" ]]; then + echo "Removing existing stash directory: $STASH_DIR" + rm -rf "$STASH_DIR" + fi +fi + +download="failed" +attempt=1 +while (( attempt <= RETRY_COUNT )); do + echo "Downloading stash (attempt $attempt of $RETRY_COUNT)..." + gh run download "$STASH_RUN_ID" \ + --name "$STASH_NAME" \ + --dir "$STASH_DIR" \ + -R "$REPO" + rc=$? + if (( rc == 0 )); then + download="success" + break + fi + if (( rc != 1 )); then + echo "::warning ::gh run download failed with exit code $rc; not retrying." + break + fi + echo "::warning ::gh run download failed with exit code 1 on attempt $attempt." + attempt=$(( attempt + 1 )) +done + +echo "download=$download" >> "$GITHUB_OUTPUT" + +if [[ "$download" != "success" && "$FAIL_ON_DOWNLOAD" == "true" ]]; then + echo "::error ::Stash artifact download failed after $RETRY_COUNT attempt(s) and fail-on-download is true." + exit 1 +fi +exit 0 diff --git a/stash/restore/test_download_stash.sh b/stash/restore/test_download_stash.sh new file mode 100755 index 00000000..8a190bda --- /dev/null +++ b/stash/restore/test_download_stash.sh @@ -0,0 +1,194 @@ +#!/usr/bin/env bash +# Copyright (c) The stash contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Unit tests for download_stash.sh. A fake `gh` binary is placed on PATH +# so that the retry logic can be exercised without touching the network. + +set -u + +this_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +script="$this_dir/download_stash.sh" + +tmp_root=$(mktemp -d) +trap 'rm -rf "$tmp_root"' EXIT + +failures=0 +cases=0 +case_dir="" + +fail() { echo "FAIL: $1"; failures=$(( failures + 1 )); } +ok() { echo "ok: $1"; } + +# Create a fake `gh` binary in $1 that returns successive exit codes taken +# from the comma-separated list $2. Once the list is exhausted, the last +# code is reused for subsequent calls. Every invocation is logged to +# "$1/.log" so tests can assert how many times `gh` was called. +make_fake_gh() { + local dir=$1 + local codes=$2 + mkdir -p "$dir" + cat >"$dir/gh" </dev/null || echo 0) +idx=\$n +if (( idx >= \${#arr[@]} )); then idx=\$(( \${#arr[@]} - 1 )); fi +echo \$(( n + 1 )) > "\$counter_file" +echo "fake-gh n=\$n rc=\${arr[\$idx]} args: \$*" >> "$dir/.log" +exit "\${arr[\$idx]}" +EOF + chmod +x "$dir/gh" + : > "$dir/.counter" + : > "$dir/.log" +} + +count_calls() { + local log="$1/.log" + if [[ -f "$log" ]]; then + wc -l < "$log" | tr -d ' ' + else + echo 0 + fi +} + +# Prepare a fresh case directory and export the env vars the script needs. +# After this, the caller can tweak RETRY_COUNT / FAIL_ON_DOWNLOAD / CLEAN +# and invoke the script. +run_case() { + cases=$(( cases + 1 )) + case_dir="$tmp_root/case_$cases" + mkdir -p "$case_dir" + export STASH_RUN_ID="42" + export STASH_NAME="fake-stash" + export STASH_DIR="$case_dir/target" + export REPO="test/repo" + export GITHUB_OUTPUT="$case_dir/github_output" + : > "$GITHUB_OUTPUT" + mkdir -p "$STASH_DIR" +} + +# --- Case 1: success on first attempt --------------------------------------- +run_case +make_fake_gh "$case_dir/bin" "0" +prev=$failures +RETRY_COUNT=3 FAIL_ON_DOWNLOAD=false CLEAN=false \ + PATH="$case_dir/bin:$PATH" bash "$script" > "$case_dir/out" 2>&1 +rc=$? +calls=$(count_calls "$case_dir/bin") +grep -q "download=success" "$GITHUB_OUTPUT" || fail "case 1: expected download=success" +[[ $rc -eq 0 ]] || fail "case 1: expected exit 0, got $rc" +[[ $calls -eq 1 ]] || fail "case 1: expected 1 gh call, got $calls" +(( failures == prev )) && ok "success on first attempt" + +# --- Case 2: success after two retries on exit code 1 ----------------------- +run_case +make_fake_gh "$case_dir/bin" "1,1,0" +prev=$failures +RETRY_COUNT=3 FAIL_ON_DOWNLOAD=false CLEAN=false \ + PATH="$case_dir/bin:$PATH" bash "$script" > "$case_dir/out" 2>&1 +rc=$? +calls=$(count_calls "$case_dir/bin") +grep -q "download=success" "$GITHUB_OUTPUT" || fail "case 2: expected download=success" +[[ $rc -eq 0 ]] || fail "case 2: expected exit 0, got $rc" +[[ $calls -eq 3 ]] || fail "case 2: expected 3 gh calls, got $calls" +(( failures == prev )) && ok "retry on exit 1 until success" + +# --- Case 3: all retries fail; fail-on-download=false -> exit 0 ------------- +run_case +make_fake_gh "$case_dir/bin" "1" +prev=$failures +RETRY_COUNT=3 FAIL_ON_DOWNLOAD=false CLEAN=false \ + PATH="$case_dir/bin:$PATH" bash "$script" > "$case_dir/out" 2>&1 +rc=$? +calls=$(count_calls "$case_dir/bin") +grep -q "download=failed" "$GITHUB_OUTPUT" || fail "case 3: expected download=failed" +[[ $rc -eq 0 ]] || fail "case 3: expected exit 0, got $rc" +[[ $calls -eq 3 ]] || fail "case 3: expected 3 gh calls, got $calls" +(( failures == prev )) && ok "all retries fail, tolerated by default" + +# --- Case 4: all retries fail; fail-on-download=true -> exit 1 -------------- +run_case +make_fake_gh "$case_dir/bin" "1" +prev=$failures +RETRY_COUNT=2 FAIL_ON_DOWNLOAD=true CLEAN=false \ + PATH="$case_dir/bin:$PATH" bash "$script" > "$case_dir/out" 2>&1 +rc=$? +calls=$(count_calls "$case_dir/bin") +grep -q "download=failed" "$GITHUB_OUTPUT" || fail "case 4: expected download=failed" +[[ $rc -eq 1 ]] || fail "case 4: expected exit 1, got $rc" +[[ $calls -eq 2 ]] || fail "case 4: expected 2 gh calls, got $calls" +grep -q "fail-on-download is true" "$case_dir/out" \ + || fail "case 4: expected error annotation in script output" +(( failures == prev )) && ok "fail-on-download=true causes exit 1" + +# --- Case 5: exit code != 1 does not retry ---------------------------------- +run_case +make_fake_gh "$case_dir/bin" "2" +prev=$failures +RETRY_COUNT=5 FAIL_ON_DOWNLOAD=false CLEAN=false \ + PATH="$case_dir/bin:$PATH" bash "$script" > "$case_dir/out" 2>&1 +rc=$? +calls=$(count_calls "$case_dir/bin") +grep -q "download=failed" "$GITHUB_OUTPUT" || fail "case 5: expected download=failed" +[[ $rc -eq 0 ]] || fail "case 5: expected exit 0, got $rc" +[[ $calls -eq 1 ]] || fail "case 5: expected 1 gh call, got $calls" +grep -q "not retrying" "$case_dir/out" \ + || fail "case 5: expected 'not retrying' message" +(( failures == prev )) && ok "exit code != 1 is not retried" + +# --- Case 6: CLEAN=true removes STASH_DIR before download ------------------- +run_case +make_fake_gh "$case_dir/bin" "0" +touch "$STASH_DIR/leftover" +prev=$failures +RETRY_COUNT=1 FAIL_ON_DOWNLOAD=false CLEAN=true \ + PATH="$case_dir/bin:$PATH" bash "$script" > "$case_dir/out" 2>&1 +rc=$? +[[ ! -e "$STASH_DIR/leftover" ]] || fail "case 6: expected stash dir cleaned" +grep -q "download=success" "$GITHUB_OUTPUT" || fail "case 6: expected download=success" +[[ $rc -eq 0 ]] || fail "case 6: expected exit 0, got $rc" +(( failures == prev )) && ok "CLEAN=true removes stash dir before download" + +# --- Case 7: CLEAN=false leaves STASH_DIR contents alone -------------------- +run_case +make_fake_gh "$case_dir/bin" "0" +touch "$STASH_DIR/leftover" +prev=$failures +RETRY_COUNT=1 FAIL_ON_DOWNLOAD=false CLEAN=false \ + PATH="$case_dir/bin:$PATH" bash "$script" > "$case_dir/out" 2>&1 +rc=$? +[[ -e "$STASH_DIR/leftover" ]] || fail "case 7: expected leftover file to remain" +grep -q "download=success" "$GITHUB_OUTPUT" || fail "case 7: expected download=success" +[[ $rc -eq 0 ]] || fail "case 7: expected exit 0, got $rc" +(( failures == prev )) && ok "CLEAN=false leaves stash dir contents" + +# --- Case 8: mixed transient+fatal — stops on first non-1 code -------------- +run_case +make_fake_gh "$case_dir/bin" "1,2,0" +prev=$failures +RETRY_COUNT=5 FAIL_ON_DOWNLOAD=false CLEAN=false \ + PATH="$case_dir/bin:$PATH" bash "$script" > "$case_dir/out" 2>&1 +rc=$? +calls=$(count_calls "$case_dir/bin") +grep -q "download=failed" "$GITHUB_OUTPUT" || fail "case 8: expected download=failed" +[[ $rc -eq 0 ]] || fail "case 8: expected exit 0, got $rc" +[[ $calls -eq 2 ]] || fail "case 8: expected 2 gh calls (1 retried, 2 stops), got $calls" +(( failures == prev )) && ok "stops retrying on first non-1 exit code" + +echo +echo "Ran $cases test cases, $failures failure(s)." +(( failures == 0 )) From cc0f6b6003fd6638502b5810c19cc7fcb1e90520 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Wed, 15 Apr 2026 00:35:23 +0200 Subject: [PATCH 3/5] stash/restore: rewrite download helper and its tests in Python Replace the download_stash.sh / test_download_stash.sh pair with a download_stash.py module whose retry, clean and fail-on-download logic is exposed via a single download_stash() function. The Download Stash step in action.yml now runs the module directly with `shell: python3 {0}`, matching the style of the existing Check for stash artifact step. test_download_stash.py uses unittest and a FakeGh stub injected via the run_download parameter, covering the same eight cases the bash tests did (success, retry-until-success, exhausted retries tolerated, exhausted retries with fail-on-download, non-transient exit not retried, CLEAN true/false, mixed transient+fatal). The Python tests replace the bash ones in the stash-action-test workflow. --- .github/workflows/stash-action-test.yml | 2 +- stash/restore/action.yml | 7 +- stash/restore/download_stash.py | 102 +++++++++++++ stash/restore/download_stash.sh | 66 -------- stash/restore/test_download_stash.py | 136 +++++++++++++++++ stash/restore/test_download_stash.sh | 194 ------------------------ 6 files changed, 244 insertions(+), 263 deletions(-) create mode 100644 stash/restore/download_stash.py delete mode 100755 stash/restore/download_stash.sh create mode 100644 stash/restore/test_download_stash.py delete mode 100755 stash/restore/test_download_stash.sh diff --git a/.github/workflows/stash-action-test.yml b/.github/workflows/stash-action-test.yml index 2a0806b4..aaa04924 100644 --- a/.github/workflows/stash-action-test.yml +++ b/.github/workflows/stash-action-test.yml @@ -71,7 +71,7 @@ jobs: run: | cd stash/restore python3 test_get_stash.py - bash test_download_stash.sh + python3 test_download_stash.py - name: Test Save uses: ./stash/save diff --git a/stash/restore/action.yml b/stash/restore/action.yml index d6337841..3c88229b 100644 --- a/stash/restore/action.yml +++ b/stash/restore/action.yml @@ -153,10 +153,11 @@ runs: ) - name: Download Stash - shell: bash + shell: python3 {0} if: steps.check-stash.outputs.stash_found != 'false' id: download env: + PYTHONPATH: "${{ github.action_path }}" GH_TOKEN: "${{ inputs.token }}" STASH_NAME: "${{ steps.check-stash.outputs.stash_name }}" STASH_RUN_ID: "${{ steps.check-stash.outputs.stash_run_id }}" @@ -166,7 +167,9 @@ runs: RETRY_COUNT: "${{ inputs.retry-count }}" FAIL_ON_DOWNLOAD: "${{ inputs.fail-on-download }}" run: | - bash "${{ github.action_path }}/download_stash.sh" + import os, sys + import download_stash + sys.exit(download_stash.download_stash(os.environ)) - name: Set stash-hit Output id: output diff --git a/stash/restore/download_stash.py b/stash/restore/download_stash.py new file mode 100644 index 00000000..fe550b21 --- /dev/null +++ b/stash/restore/download_stash.py @@ -0,0 +1,102 @@ +# Copyright (c) The stash contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Download a stash artifact with retries. + +Required env vars: + STASH_RUN_ID - workflow run ID the artifact was produced by + STASH_NAME - artifact name + STASH_DIR - destination directory + REPO - owner/name of the repository + RETRY_COUNT - max download attempts (retries on gh exit code 1) + FAIL_ON_DOWNLOAD - "true" to exit 1 on download failure, else "false" + CLEAN - "true" to remove STASH_DIR before downloading + GITHUB_OUTPUT - file to write the `download` output to +""" + +import os +import shutil +import subprocess +import sys +from typing import Callable, Mapping + + +def run_gh_download(run_id: str, name: str, dest: str, repo: str) -> int: + """Invoke ``gh run download`` and return its exit code.""" + return subprocess.run( + [ + "gh", "run", "download", run_id, + "--name", name, + "--dir", dest, + "-R", repo, + ], + check=False, + ).returncode + + +def download_stash( + env: Mapping[str, str], + run_download: Callable[[str, str, str, str], int] = run_gh_download, +) -> int: + """Run the clean/retry/fail-on-download logic. + + Returns the desired process exit code (0 for success or tolerated + failure, 1 when the download failed and ``FAIL_ON_DOWNLOAD`` is + ``"true"``). The ``run_download`` hook exists so tests can stub out + the real ``gh`` call. + """ + stash_run_id = env["STASH_RUN_ID"] + stash_name = env["STASH_NAME"] + stash_dir = env["STASH_DIR"] + repo = env["REPO"] + retry_count = int(env.get("RETRY_COUNT", "1")) + fail_on_download = env.get("FAIL_ON_DOWNLOAD", "false").lower() == "true" + clean = env.get("CLEAN", "false").lower() == "true" + github_output = env["GITHUB_OUTPUT"] + + if clean and os.path.isdir(stash_dir): + print(f"Removing existing stash directory: {stash_dir}") + shutil.rmtree(stash_dir, ignore_errors=True) + + download = "failed" + for attempt in range(1, retry_count + 1): + print(f"Downloading stash (attempt {attempt} of {retry_count})...", flush=True) + rc = run_download(stash_run_id, stash_name, stash_dir, repo) + if rc == 0: + download = "success" + break + if rc != 1: + print( + f"::warning ::gh run download failed with exit code {rc}; " + "not retrying." + ) + break + print( + f"::warning ::gh run download failed with exit code 1 on " + f"attempt {attempt}." + ) + + with open(github_output, "a", encoding="utf-8") as f: + f.write(f"download={download}\n") + + if download != "success" and fail_on_download: + print( + f"::error ::Stash artifact download failed after {retry_count} " + "attempt(s) and fail-on-download is true." + ) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(download_stash(os.environ)) diff --git a/stash/restore/download_stash.sh b/stash/restore/download_stash.sh deleted file mode 100755 index 71262e26..00000000 --- a/stash/restore/download_stash.sh +++ /dev/null @@ -1,66 +0,0 @@ -#!/usr/bin/env bash -# Copyright (c) The stash contributors -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# Download a stash artifact with retries. -# -# Required env vars: -# STASH_RUN_ID - workflow run ID the artifact was produced by -# STASH_NAME - artifact name -# STASH_DIR - destination directory -# REPO - owner/name of the repository -# RETRY_COUNT - max download attempts (retries on gh exit code 1) -# FAIL_ON_DOWNLOAD - "true" to exit 1 on download failure, else "false" -# CLEAN - "true" to remove STASH_DIR before downloading -# GITHUB_OUTPUT - file to write the `download` output to - -# Disable errexit explicitly so a single failing command (gh run download, -# rm -rf) cannot abort the step — failures are handled via $? below. -set +e - -if [[ "${CLEAN}" == "true" ]]; then - if [[ -d "$STASH_DIR" ]]; then - echo "Removing existing stash directory: $STASH_DIR" - rm -rf "$STASH_DIR" - fi -fi - -download="failed" -attempt=1 -while (( attempt <= RETRY_COUNT )); do - echo "Downloading stash (attempt $attempt of $RETRY_COUNT)..." - gh run download "$STASH_RUN_ID" \ - --name "$STASH_NAME" \ - --dir "$STASH_DIR" \ - -R "$REPO" - rc=$? - if (( rc == 0 )); then - download="success" - break - fi - if (( rc != 1 )); then - echo "::warning ::gh run download failed with exit code $rc; not retrying." - break - fi - echo "::warning ::gh run download failed with exit code 1 on attempt $attempt." - attempt=$(( attempt + 1 )) -done - -echo "download=$download" >> "$GITHUB_OUTPUT" - -if [[ "$download" != "success" && "$FAIL_ON_DOWNLOAD" == "true" ]]; then - echo "::error ::Stash artifact download failed after $RETRY_COUNT attempt(s) and fail-on-download is true." - exit 1 -fi -exit 0 diff --git a/stash/restore/test_download_stash.py b/stash/restore/test_download_stash.py new file mode 100644 index 00000000..5b31ea06 --- /dev/null +++ b/stash/restore/test_download_stash.py @@ -0,0 +1,136 @@ +# Copyright (c) The stash contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Unit tests for download_stash.download_stash.""" + +import tempfile +import unittest +from pathlib import Path + +from download_stash import download_stash + + +class FakeGh: + """Emits successive exit codes for ``gh run download`` invocations. + + After the provided list of codes is exhausted, the last code is + reused for any further calls. Each call is recorded so tests can + assert how many times ``gh`` was invoked. + """ + + def __init__(self, codes): + self.codes = list(codes) + self.calls = [] + + def __call__(self, run_id, name, dest, repo): + self.calls.append((run_id, name, dest, repo)) + idx = min(len(self.calls) - 1, len(self.codes) - 1) + return self.codes[idx] + + +class TestDownloadStash(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.tmp = Path(self._tmp.name) + self.stash_dir = self.tmp / "target" + self.stash_dir.mkdir() + self.output_file = self.tmp / "github_output" + self.output_file.touch() + + def tearDown(self): + self._tmp.cleanup() + + def env(self, **overrides): + base = { + "STASH_RUN_ID": "42", + "STASH_NAME": "fake-stash", + "STASH_DIR": str(self.stash_dir), + "REPO": "test/repo", + "RETRY_COUNT": "3", + "FAIL_ON_DOWNLOAD": "false", + "CLEAN": "false", + "GITHUB_OUTPUT": str(self.output_file), + } + base.update(overrides) + return base + + def read_output(self): + return self.output_file.read_text() + + def test_success_first_attempt(self): + gh = FakeGh([0]) + rc = download_stash(self.env(), run_download=gh) + self.assertEqual(rc, 0) + self.assertIn("download=success", self.read_output()) + self.assertEqual(len(gh.calls), 1) + + def test_retry_on_exit_1_until_success(self): + gh = FakeGh([1, 1, 0]) + rc = download_stash(self.env(), run_download=gh) + self.assertEqual(rc, 0) + self.assertIn("download=success", self.read_output()) + self.assertEqual(len(gh.calls), 3) + + def test_all_retries_fail_tolerated(self): + gh = FakeGh([1]) + rc = download_stash(self.env(), run_download=gh) + self.assertEqual(rc, 0) + self.assertIn("download=failed", self.read_output()) + self.assertEqual(len(gh.calls), 3) + + def test_all_retries_fail_fail_on_download(self): + gh = FakeGh([1]) + rc = download_stash( + self.env(RETRY_COUNT="2", FAIL_ON_DOWNLOAD="true"), + run_download=gh, + ) + self.assertEqual(rc, 1) + self.assertIn("download=failed", self.read_output()) + self.assertEqual(len(gh.calls), 2) + + def test_non_transient_exit_not_retried(self): + gh = FakeGh([2]) + rc = download_stash(self.env(RETRY_COUNT="5"), run_download=gh) + self.assertEqual(rc, 0) + self.assertIn("download=failed", self.read_output()) + self.assertEqual(len(gh.calls), 1) + + def test_clean_removes_stash_dir(self): + (self.stash_dir / "leftover").touch() + gh = FakeGh([0]) + rc = download_stash( + self.env(CLEAN="true", RETRY_COUNT="1"), run_download=gh + ) + self.assertEqual(rc, 0) + self.assertFalse((self.stash_dir / "leftover").exists()) + self.assertIn("download=success", self.read_output()) + + def test_clean_false_preserves_stash_dir(self): + (self.stash_dir / "leftover").touch() + gh = FakeGh([0]) + rc = download_stash( + self.env(CLEAN="false", RETRY_COUNT="1"), run_download=gh + ) + self.assertEqual(rc, 0) + self.assertTrue((self.stash_dir / "leftover").exists()) + + def test_stops_on_first_non_transient(self): + gh = FakeGh([1, 2, 0]) + rc = download_stash(self.env(RETRY_COUNT="5"), run_download=gh) + self.assertEqual(rc, 0) + self.assertIn("download=failed", self.read_output()) + self.assertEqual(len(gh.calls), 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/stash/restore/test_download_stash.sh b/stash/restore/test_download_stash.sh deleted file mode 100755 index 8a190bda..00000000 --- a/stash/restore/test_download_stash.sh +++ /dev/null @@ -1,194 +0,0 @@ -#!/usr/bin/env bash -# Copyright (c) The stash contributors -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# Unit tests for download_stash.sh. A fake `gh` binary is placed on PATH -# so that the retry logic can be exercised without touching the network. - -set -u - -this_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) -script="$this_dir/download_stash.sh" - -tmp_root=$(mktemp -d) -trap 'rm -rf "$tmp_root"' EXIT - -failures=0 -cases=0 -case_dir="" - -fail() { echo "FAIL: $1"; failures=$(( failures + 1 )); } -ok() { echo "ok: $1"; } - -# Create a fake `gh` binary in $1 that returns successive exit codes taken -# from the comma-separated list $2. Once the list is exhausted, the last -# code is reused for subsequent calls. Every invocation is logged to -# "$1/.log" so tests can assert how many times `gh` was called. -make_fake_gh() { - local dir=$1 - local codes=$2 - mkdir -p "$dir" - cat >"$dir/gh" </dev/null || echo 0) -idx=\$n -if (( idx >= \${#arr[@]} )); then idx=\$(( \${#arr[@]} - 1 )); fi -echo \$(( n + 1 )) > "\$counter_file" -echo "fake-gh n=\$n rc=\${arr[\$idx]} args: \$*" >> "$dir/.log" -exit "\${arr[\$idx]}" -EOF - chmod +x "$dir/gh" - : > "$dir/.counter" - : > "$dir/.log" -} - -count_calls() { - local log="$1/.log" - if [[ -f "$log" ]]; then - wc -l < "$log" | tr -d ' ' - else - echo 0 - fi -} - -# Prepare a fresh case directory and export the env vars the script needs. -# After this, the caller can tweak RETRY_COUNT / FAIL_ON_DOWNLOAD / CLEAN -# and invoke the script. -run_case() { - cases=$(( cases + 1 )) - case_dir="$tmp_root/case_$cases" - mkdir -p "$case_dir" - export STASH_RUN_ID="42" - export STASH_NAME="fake-stash" - export STASH_DIR="$case_dir/target" - export REPO="test/repo" - export GITHUB_OUTPUT="$case_dir/github_output" - : > "$GITHUB_OUTPUT" - mkdir -p "$STASH_DIR" -} - -# --- Case 1: success on first attempt --------------------------------------- -run_case -make_fake_gh "$case_dir/bin" "0" -prev=$failures -RETRY_COUNT=3 FAIL_ON_DOWNLOAD=false CLEAN=false \ - PATH="$case_dir/bin:$PATH" bash "$script" > "$case_dir/out" 2>&1 -rc=$? -calls=$(count_calls "$case_dir/bin") -grep -q "download=success" "$GITHUB_OUTPUT" || fail "case 1: expected download=success" -[[ $rc -eq 0 ]] || fail "case 1: expected exit 0, got $rc" -[[ $calls -eq 1 ]] || fail "case 1: expected 1 gh call, got $calls" -(( failures == prev )) && ok "success on first attempt" - -# --- Case 2: success after two retries on exit code 1 ----------------------- -run_case -make_fake_gh "$case_dir/bin" "1,1,0" -prev=$failures -RETRY_COUNT=3 FAIL_ON_DOWNLOAD=false CLEAN=false \ - PATH="$case_dir/bin:$PATH" bash "$script" > "$case_dir/out" 2>&1 -rc=$? -calls=$(count_calls "$case_dir/bin") -grep -q "download=success" "$GITHUB_OUTPUT" || fail "case 2: expected download=success" -[[ $rc -eq 0 ]] || fail "case 2: expected exit 0, got $rc" -[[ $calls -eq 3 ]] || fail "case 2: expected 3 gh calls, got $calls" -(( failures == prev )) && ok "retry on exit 1 until success" - -# --- Case 3: all retries fail; fail-on-download=false -> exit 0 ------------- -run_case -make_fake_gh "$case_dir/bin" "1" -prev=$failures -RETRY_COUNT=3 FAIL_ON_DOWNLOAD=false CLEAN=false \ - PATH="$case_dir/bin:$PATH" bash "$script" > "$case_dir/out" 2>&1 -rc=$? -calls=$(count_calls "$case_dir/bin") -grep -q "download=failed" "$GITHUB_OUTPUT" || fail "case 3: expected download=failed" -[[ $rc -eq 0 ]] || fail "case 3: expected exit 0, got $rc" -[[ $calls -eq 3 ]] || fail "case 3: expected 3 gh calls, got $calls" -(( failures == prev )) && ok "all retries fail, tolerated by default" - -# --- Case 4: all retries fail; fail-on-download=true -> exit 1 -------------- -run_case -make_fake_gh "$case_dir/bin" "1" -prev=$failures -RETRY_COUNT=2 FAIL_ON_DOWNLOAD=true CLEAN=false \ - PATH="$case_dir/bin:$PATH" bash "$script" > "$case_dir/out" 2>&1 -rc=$? -calls=$(count_calls "$case_dir/bin") -grep -q "download=failed" "$GITHUB_OUTPUT" || fail "case 4: expected download=failed" -[[ $rc -eq 1 ]] || fail "case 4: expected exit 1, got $rc" -[[ $calls -eq 2 ]] || fail "case 4: expected 2 gh calls, got $calls" -grep -q "fail-on-download is true" "$case_dir/out" \ - || fail "case 4: expected error annotation in script output" -(( failures == prev )) && ok "fail-on-download=true causes exit 1" - -# --- Case 5: exit code != 1 does not retry ---------------------------------- -run_case -make_fake_gh "$case_dir/bin" "2" -prev=$failures -RETRY_COUNT=5 FAIL_ON_DOWNLOAD=false CLEAN=false \ - PATH="$case_dir/bin:$PATH" bash "$script" > "$case_dir/out" 2>&1 -rc=$? -calls=$(count_calls "$case_dir/bin") -grep -q "download=failed" "$GITHUB_OUTPUT" || fail "case 5: expected download=failed" -[[ $rc -eq 0 ]] || fail "case 5: expected exit 0, got $rc" -[[ $calls -eq 1 ]] || fail "case 5: expected 1 gh call, got $calls" -grep -q "not retrying" "$case_dir/out" \ - || fail "case 5: expected 'not retrying' message" -(( failures == prev )) && ok "exit code != 1 is not retried" - -# --- Case 6: CLEAN=true removes STASH_DIR before download ------------------- -run_case -make_fake_gh "$case_dir/bin" "0" -touch "$STASH_DIR/leftover" -prev=$failures -RETRY_COUNT=1 FAIL_ON_DOWNLOAD=false CLEAN=true \ - PATH="$case_dir/bin:$PATH" bash "$script" > "$case_dir/out" 2>&1 -rc=$? -[[ ! -e "$STASH_DIR/leftover" ]] || fail "case 6: expected stash dir cleaned" -grep -q "download=success" "$GITHUB_OUTPUT" || fail "case 6: expected download=success" -[[ $rc -eq 0 ]] || fail "case 6: expected exit 0, got $rc" -(( failures == prev )) && ok "CLEAN=true removes stash dir before download" - -# --- Case 7: CLEAN=false leaves STASH_DIR contents alone -------------------- -run_case -make_fake_gh "$case_dir/bin" "0" -touch "$STASH_DIR/leftover" -prev=$failures -RETRY_COUNT=1 FAIL_ON_DOWNLOAD=false CLEAN=false \ - PATH="$case_dir/bin:$PATH" bash "$script" > "$case_dir/out" 2>&1 -rc=$? -[[ -e "$STASH_DIR/leftover" ]] || fail "case 7: expected leftover file to remain" -grep -q "download=success" "$GITHUB_OUTPUT" || fail "case 7: expected download=success" -[[ $rc -eq 0 ]] || fail "case 7: expected exit 0, got $rc" -(( failures == prev )) && ok "CLEAN=false leaves stash dir contents" - -# --- Case 8: mixed transient+fatal — stops on first non-1 code -------------- -run_case -make_fake_gh "$case_dir/bin" "1,2,0" -prev=$failures -RETRY_COUNT=5 FAIL_ON_DOWNLOAD=false CLEAN=false \ - PATH="$case_dir/bin:$PATH" bash "$script" > "$case_dir/out" 2>&1 -rc=$? -calls=$(count_calls "$case_dir/bin") -grep -q "download=failed" "$GITHUB_OUTPUT" || fail "case 8: expected download=failed" -[[ $rc -eq 0 ]] || fail "case 8: expected exit 0, got $rc" -[[ $calls -eq 2 ]] || fail "case 8: expected 2 gh calls (1 retried, 2 stops), got $calls" -(( failures == prev )) && ok "stops retrying on first non-1 exit code" - -echo -echo "Ran $cases test cases, $failures failure(s)." -(( failures == 0 )) From b05768ef8cc4dbd0cc897014357abf9e0ea95bb7 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Wed, 15 Apr 2026 00:43:15 +0200 Subject: [PATCH 4/5] stash: add pyproject.toml and scope pelican lint workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add stash/pyproject.toml modeled on pelican's: no runtime Python deps (the action uses the `gh` and `jq` CLIs, which the action itself checks for), a PEP 735 `dev` group with ruff/mypy/pytest for local development, Hatch env wiring, and a ruff configuration. Scope the "Linting and MyPy (Pelican)" workflow trigger from the catch-all `**.py` / `**/linting.yml` / `**/pylintrc` to the pelican subtree plus the workflow file itself. The workflow cds into pelican and only knows how to lint that directory, so non-pelican Python changes should not trigger it — this is what was making the lint job on PR 716 fail (unrelated pelican install step). Also fix one ruff finding in download_stash.py: import `Callable` and `Mapping` from `collections.abc` instead of `typing` (UP035). --- .github/workflows/linting.yml | 7 +-- stash/pyproject.toml | 92 +++++++++++++++++++++++++++++++++ stash/restore/download_stash.py | 2 +- 3 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 stash/pyproject.toml diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index 8a87408d..28c114e5 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -21,9 +21,10 @@ name: Linting and MyPy (Pelican) on: push: paths: - - '**.py' - - '**/linting.yml' - - '**/pylintrc' + - 'pelican/**.py' + - 'pelican/pyproject.toml' + - 'pelican/pylintrc' + - '.github/workflows/linting.yml' workflow_dispatch: permissions: diff --git a/stash/pyproject.toml b/stash/pyproject.toml new file mode 100644 index 00000000..21b64469 --- /dev/null +++ b/stash/pyproject.toml @@ -0,0 +1,92 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# + +[build-system] +requires = ["setuptools>=82.0.1"] +build-backend = "setuptools.build_meta" + +[project] +name = "apache-stash-action" +version = "0.1.0" +description = "GitHub Action for stashing and restoring build caches via workflow artifacts." +readme = "README.md" +license = "Apache-2.0" +requires-python = ">=3.10" +authors = [ + { name = "Apache Software Foundation", email = "dev@infra.apache.org" }, +] +keywords = ["github-action", "cache", "artifacts", "apache"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Software Development :: Build Tools", +] +# The stash action's Python code is stdlib-only at runtime; the external +# dependencies are the `gh` and `jq` CLIs, which are installed on the +# GitHub Actions runner and checked for at action start-up. +dependencies = [] + +# PEP 735 dependency groups. `dev` holds everything a contributor needs +# to lint and test the stash helpers locally. pytest is listed so the +# existing unittest-based tests can be discovered and run via pytest if +# desired, alongside `python3 test_*.py`. +[dependency-groups] +dev = [ + "ruff>=0.6", + "mypy>=1.10", + "pytest>=8", +] + +[project.urls] +Homepage = "https://github.com/apache/infrastructure-actions" +Source = "https://github.com/apache/infrastructure-actions" +Issues = "https://github.com/apache/infrastructure-actions/issues" + +[tool.setuptools] +py-modules = [] + +[tool.uv] +required-version = ">=0.5.0" + +[tool.hatch.envs.default] +installer = "uv" +dependency-groups = ["dev"] + +[tool.hatch.envs.default.scripts] +lint = "ruff check ." +fmt = "ruff format ." +test = "pytest" + +[tool.ruff] +line-length = 100 +target-version = "py310" + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "UP", "B", "SIM"] +ignore = ["E501"] + +[tool.ruff.format] +quote-style = "double" diff --git a/stash/restore/download_stash.py b/stash/restore/download_stash.py index fe550b21..5e14818a 100644 --- a/stash/restore/download_stash.py +++ b/stash/restore/download_stash.py @@ -28,7 +28,7 @@ import shutil import subprocess import sys -from typing import Callable, Mapping +from collections.abc import Callable, Mapping def run_gh_download(run_id: str, name: str, dest: str, repo: str) -> int: From 55170228448131215a40ee07495e0fc1293e6518 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Sat, 18 Apr 2026 09:54:11 +0200 Subject: [PATCH 5/5] ci: restore credentials on test-site checkout for pelican publish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A recent zizmor-driven hardening pass added `persist-credentials: false` to both actions/checkout steps in pelican-action-test.yml. The test workflow drives the pelican action end-to-end, and the action's publish path runs `git push` against the test-site working tree — without persisted credentials that push fails with "could not read Username for 'https://github.com'", turning the pelican-test job red. Drop persist-credentials: false from the first checkout (the test-site working tree that actually pushes). The second checkout into self/ is only used to load the action code and keeps persist-credentials: false. Generated-by: Claude Opus 4.7 (1M context) --- .github/workflows/pelican-action-test.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pelican-action-test.yml b/.github/workflows/pelican-action-test.yml index 42341828..4e5c528d 100644 --- a/.github/workflows/pelican-action-test.yml +++ b/.github/workflows/pelican-action-test.yml @@ -41,7 +41,8 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ env.SOURCE }} - persist-credentials: false + # Credentials must persist: the pelican action's publish step runs + # `git push` against this working tree to commit the built site. - name: Ignore the action checkout run: | echo "self/" >> .git/info/exclude