From 618838006df737e5518cd29b20432c3dcf989d60 Mon Sep 17 00:00:00 2001 From: woksin Date: Sat, 24 Oct 2026 11:01:00 +0200 Subject: [PATCH 1/2] Add skip-if-already-ran action for scheduled runs on an unchanged commit Scheduled workflows that validate their own commit can skip when the same workflow already succeeded on the same head SHA. Manual dispatches and re-runs always run, and any API error runs normally. --- .../actions/skip-if-already-ran/action.yml | 89 ++++++++++ .../scripts/tests/skip-if-already-ran.test.py | 166 ++++++++++++++++++ .../workflows/verify-skip-if-already-ran.yml | 56 ++++++ README.md | 29 +++ 4 files changed, 340 insertions(+) create mode 100644 .github/actions/skip-if-already-ran/action.yml create mode 100644 .github/scripts/tests/skip-if-already-ran.test.py create mode 100644 .github/workflows/verify-skip-if-already-ran.yml diff --git a/.github/actions/skip-if-already-ran/action.yml b/.github/actions/skip-if-already-ran/action.yml new file mode 100644 index 0000000..d84266d --- /dev/null +++ b/.github/actions/skip-if-already-ran/action.yml @@ -0,0 +1,89 @@ +# Copyright (c) Cratis. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for full license information. +# Caller contract: the job needs permissions: actions: read and runs on the repo's +# own cheapest runner (cratis-arc / RUNNER_GATE for private repositories). +# Only successful runs count; failed or cancelled runs run again next night. +# Floating toolchains and other inputs outside the commit are not part of the key. +# Do not use in a reusable workflow that has a scheduled caller: it sees the caller's runs. +name: Skip if already ran +description: Reports whether this workflow already succeeded for the same commit and an allowed event. + +inputs: + events: + description: Comma-separated events eligible for skipping and counted as prior successes. + required: false + default: schedule + sha: + description: Commit to check; override for workflows that push their own bot commit. + required: false + default: ${{ github.sha }} + token: + description: Token with actions read permission in the current repository. + required: false + default: ${{ github.token }} + +outputs: + skip: + description: "'true' when a prior success exists; otherwise 'false', including on API errors." + value: ${{ steps.check.outputs.skip }} + previous-run-url: + description: Link to the prior successful run, or empty when not skipping. + value: ${{ steps.check.outputs.previous-run-url }} + +runs: + using: composite + steps: + - name: Check for a prior successful run + id: check + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + EVENTS: ${{ inputs.events }} + SHA: ${{ inputs.sha }} + GH_TOKEN: ${{ inputs.token }} + REPO: ${{ github.repository }} + RUN_ID: ${{ github.run_id }} + RUN_ATTEMPT: ${{ github.run_attempt }} + run: | + set -euo pipefail + run_needed() { + printf 'skip=false\nprevious-run-url=\n' >> "$GITHUB_OUTPUT" + } + fail_open() { + echo "::warning::skip-if-already-ran: $1; running normally" + run_needed + exit 0 + } + if [[ "$EVENT_NAME" == workflow_dispatch || "$RUN_ATTEMPT" -gt 1 ]]; then + run_needed + exit 0 + fi + case ",$EVENTS," in + *",$EVENT_NAME,"*) ;; + *) run_needed; exit 0 ;; + esac + command -v gh >/dev/null || fail_open "gh is not installed" + if ! workflow_id=$(gh api "repos/$REPO/actions/runs/$RUN_ID" --jq .workflow_id); then + fail_open "Could not resolve the workflow id" + fi + [[ "$workflow_id" =~ ^[0-9]+$ ]] || fail_open "Invalid workflow id" + # The single quotes are deliberate: $ENV and $event are jq variables, not shell ones. + # shellcheck disable=SC2016 + if ! previous_url=$(gh api \ + "repos/$REPO/actions/workflows/$workflow_id/runs?head_sha=$SHA&status=success&per_page=100" \ + --jq '.workflow_runs | map(select(.id != ($ENV.RUN_ID | tonumber) and + (.event as $event | ($ENV.EVENTS | split(",") | index($event)) != null))) | + .[0].html_url // empty'); then + fail_open "Could not query prior successful runs" + fi + if [[ -z "$previous_url" ]]; then + run_needed + exit 0 + fi + [[ "$previous_url" =~ ^https://[^[:space:]]+$ ]] || fail_open "Invalid previous run URL" + if ! printf 'Skipped: this workflow already succeeded on this commit: [previous run](%s).\n' \ + "$previous_url" >> "$GITHUB_STEP_SUMMARY"; then + fail_open "Could not write the step summary" + fi + printf 'skip=true\nprevious-run-url=%s\n' "$previous_url" >> "$GITHUB_OUTPUT" + echo "::notice title=skip-if-already-ran::Already succeeded on this commit: $previous_url" diff --git a/.github/scripts/tests/skip-if-already-ran.test.py b/.github/scripts/tests/skip-if-already-ran.test.py new file mode 100644 index 0000000..a6a82ca --- /dev/null +++ b/.github/scripts/tests/skip-if-already-ran.test.py @@ -0,0 +1,166 @@ +# Copyright (c) Cratis. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for full license information. +"""Run the production composite script offline, with a fake gh and real jq.""" +from pathlib import Path +import json +import re +import shutil +import subprocess +import sys +import tempfile +import unittest + +import yaml + +ROOT = Path(__file__).resolve().parents[3] +ACTION = ROOT / ".github/actions/skip-if-already-ran/action.yml" +BASH = shutil.which("bash") +JQ = shutil.which("jq") +SHA = "a" * 40 +URL = "https://github.com/Cratis/Workflows/actions/runs/41" +FAKE_GH = ''' +import json, os, subprocess, sys +from pathlib import Path +with open(os.environ["CALLS"], "a") as log: + log.write(json.dumps(sys.argv[1:]) + "\\n") +is_lookup = "/actions/runs/" in sys.argv[2] +if os.environ["FAIL_AT"] == ("lookup" if is_lookup else "runs"): + sys.exit(1) +data = {"workflow_id": 7} if is_lookup else json.loads(Path(os.environ["RUNS"]).read_text()) +result = subprocess.run([os.environ["JQ"], "-r", sys.argv[4]], input=json.dumps(data), text=True) +sys.exit(result.returncode) +''' + + +def load(): + return yaml.safe_load(ACTION.read_text()) + + +def check_step(): + return next(s for s in load()["runs"]["steps"] if s.get("id") == "check") + + +def prior_run(): + return dict(id=41, event="schedule", head_sha=SHA, conclusion="success", html_url=URL) + + +def execute(runs=None, *, missing_gh=False, response=None, **overrides): + with tempfile.TemporaryDirectory(dir=ROOT / ".ai-work") as temp: + directory = Path(temp) + for name in ("output", "summary", "calls"): + (directory / name).write_text("") + (directory / "runs").write_text(json.dumps( + response if response is not None else {"workflow_runs": runs or []})) + if not missing_gh: + fake = directory / "gh" + fake.write_text(f"#!{sys.executable}\n" + FAKE_GH) + fake.chmod(0o755) + env = dict(PATH=temp, GITHUB_OUTPUT=str(directory / "output"), + GITHUB_STEP_SUMMARY=str(directory / "summary"), GH_TOKEN="test-token", + EVENT_NAME="schedule", EVENTS="schedule", SHA=SHA, GITHUB_SHA=SHA, + REPO="Cratis/Workflows", RUN_ID="42", RUN_ATTEMPT="1", + FAIL_AT="", JQ=JQ, RUNS=str(directory / "runs"), CALLS=str(directory / "calls")) + env.update(overrides) + env["GITHUB_RUN_ATTEMPT"] = env["RUN_ATTEMPT"] + result = subprocess.run([BASH, "-c", check_step()["run"]], env=env, + cwd=ROOT, capture_output=True, text=True) + outputs = dict(line.split("=", 1) for line in (directory / "output").read_text().splitlines()) + calls = [json.loads(line) for line in (directory / "calls").read_text().splitlines()] + return result, outputs, calls, (directory / "summary").read_text() + + +class ActionShapeTests(unittest.TestCase): + def test_composite_contract(self): + action = load() + self.assertEqual(action["runs"]["using"], "composite") + self.assertEqual(set(action["outputs"]), {"skip", "previous-run-url"}) + for name in action["outputs"]: + self.assertEqual(action["outputs"][name]["value"], "${{ steps.check.outputs." + name + " }}") + self.assertEqual({key: value["default"] for key, value in action["inputs"].items()}, + dict(events="schedule", sha="${{ github.sha }}", token="${{ github.token }}")) + self.assertEqual(check_step()["shell"], "bash") + self.assertTrue(check_step()["run"].startswith("set -euo pipefail\n")) + self.assertNotIn("${{", check_step()["run"]) + expected = dict(EVENT_NAME="github.event_name", EVENTS="inputs.events", SHA="inputs.sha", + GH_TOKEN="inputs.token", REPO="github.repository", RUN_ID="github.run_id", + RUN_ATTEMPT="github.run_attempt") + self.assertEqual(check_step()["env"], {k: "${{ " + v + " }}" for k, v in expected.items()}) + for step in action["runs"]["steps"]: + if "uses" in step: + self.assertRegex(step["uses"], re.compile(r"^[^@\s]+@[0-9a-f]{40}$")) + + def test_bash_syntax_and_shellcheck(self): + script = check_step()["run"] + subprocess.run([BASH, "-n"], input=script, text=True, check=True) + if shutil.which("shellcheck"): + subprocess.run(["shellcheck", "--shell=bash", "-"], input=script, text=True, check=True) + + +class ActionBehaviorTests(unittest.TestCase): + def assert_result(self, execution, skip): + result, outputs, calls, summary = execution + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(outputs, {"skip": str(skip).lower(), "previous-run-url": URL if skip else ""}) + return result.stdout, calls, summary + + def test_manual_dispatch_always_runs_even_when_listed(self): + _, calls, _ = self.assert_result(execute(EVENT_NAME="workflow_dispatch", EVENTS="workflow_dispatch"), False) + self.assertEqual(calls, []) + + def test_unlisted_event_does_not_query(self): + _, calls, _ = self.assert_result(execute(EVENT_NAME="pull_request"), False) + self.assertEqual(calls, []) + + def test_event_matching_is_not_a_substring(self): + _, calls, _ = self.assert_result(execute(EVENTS="schedule_other"), False) + self.assertEqual(calls, []) + + def test_manual_rerun_does_not_query(self): + _, calls, _ = self.assert_result(execute(RUN_ATTEMPT="2"), False) + self.assertEqual(calls, []) + + def test_prior_success_skips_with_notice_and_summary_link(self): + stdout, calls, summary = self.assert_result(execute(runs=[prior_run()]), True) + self.assertIn("::notice title=skip-if-already-ran::", stdout) + self.assertIn(URL, stdout) + self.assertIn(URL, summary) + self.assertEqual(calls[0], ["api", "repos/Cratis/Workflows/actions/runs/42", "--jq", ".workflow_id"]) + self.assertEqual(calls[1][1], + f"repos/Cratis/Workflows/actions/workflows/7/runs?head_sha={SHA}&status=success&per_page=100") + + def test_current_run_is_not_a_prior_success(self): + run = prior_run() + run["id"] = 42 + self.assert_result(execute(runs=[run]), False) + + def test_events_control_which_prior_runs_count(self): + run = prior_run() + run["event"] = "push" + self.assert_result(execute(runs=[run]), False) + self.assert_result(execute(runs=[run], EVENTS="schedule,push"), True) + + def test_no_success_runs_again(self): + self.assert_result(execute(), False) + + def test_sha_override_is_used_in_query(self): + _, calls, _ = self.assert_result(execute(SHA="b" * 40), False) + self.assertIn("head_sha=" + "b" * 40, calls[1][1]) + + def test_api_errors_fail_open_at_either_request(self): + for fail_at in ("lookup", "runs"): + with self.subTest(fail_at=fail_at): + stdout, _, _ = self.assert_result(execute(FAIL_AT=fail_at), False) + self.assertIn("::warning::", stdout) + + def test_missing_gh_fails_open(self): + stdout, calls, _ = self.assert_result(execute(missing_gh=True), False) + self.assertIn("::warning::", stdout) + self.assertEqual(calls, []) + + def test_malformed_api_response_fails_open(self): + stdout, _, _ = self.assert_result(execute(response={"message": "not run data"}), False) + self.assertIn("::warning::", stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/verify-skip-if-already-ran.yml b/.github/workflows/verify-skip-if-already-ran.yml new file mode 100644 index 0000000..aff1d28 --- /dev/null +++ b/.github/workflows/verify-skip-if-already-ran.yml @@ -0,0 +1,56 @@ +# Copyright (c) Cratis. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for full license information. +name: Verify Skip If Already Ran + +on: + pull_request: + paths: + - .github/actions/skip-if-already-ran/** + - .github/scripts/tests/skip-if-already-ran.test.py + - .github/workflows/verify-skip-if-already-ran.yml + push: + branches: [main] + paths: + - .github/actions/skip-if-already-ran/** + - .github/scripts/tests/skip-if-already-ran.test.py + - .github/workflows/verify-skip-if-already-ran.yml + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + actions: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Check the action offline + run: python3 -B .github/scripts/tests/skip-if-already-ran.test.py + - name: Default events never skip pull requests or pushes + id: defaults + uses: ./.github/actions/skip-if-already-ran + - name: Exercise the real API for the current event + id: live + uses: ./.github/actions/skip-if-already-ran + with: + events: ${{ github.event_name }} + - name: Assert live outputs + env: + DEFAULT_SKIP: ${{ steps.defaults.outputs.skip }} + LIVE_SKIP: ${{ steps.live.outputs.skip }} + LIVE_OUTCOME: ${{ steps.live.outcome }} + run: | + set -euo pipefail + [[ "$DEFAULT_SKIP" == false ]] + [[ "$LIVE_SKIP" == true || "$LIVE_SKIP" == false ]] + [[ "$LIVE_OUTCOME" == success ]] + echo "Checked 3 live action assertions" diff --git a/README.md b/README.md index 896032a..fccab22 100644 --- a/README.md +++ b/README.md @@ -725,6 +725,35 @@ The key is `--/yarn.lock, < --- +## Skip scheduled runs that already ran on this commit + +`Cratis/Workflows/.github/actions/skip-if-already-ran@main` reports `skip=true` when the same workflow already succeeded on the same commit for an event in `events` (default: `schedule`). Failed or cancelled runs do not count. Manual dispatches and manual re-runs always run; API errors warn and run normally. `previous-run-url` links to the prior success. See [#205](https://github.com/Cratis/Workflows/issues/205). + +Place the step in an existing cheap gate job and expose its output, or add a small `already-ran` job: + +```yaml +already-ran: + runs-on: ubuntu-latest # Private repos: use cratis-arc / your RUNNER_GATE instead. + timeout-minutes: 5 + permissions: + actions: read + outputs: + skip: ${{ steps.check.outputs.skip }} + steps: + - id: check + uses: Cratis/Workflows/.github/actions/skip-if-already-ran@main +build: + needs: [already-ran] + if: needs.already-ran.outputs.skip != 'true' + # ...existing job configuration... +``` + +**Every other job must directly list the gate in `needs`**, and add `needs..outputs.skip != 'true' && (…existing condition…)` to its `if` (or just the skip condition if it has none). This includes `always()`, `!cancelled()` and `failure()` aggregators and notification jobs; otherwise a deliberate skip can report red. Do not use the action in a reusable workflow with a scheduled caller: it would look up the caller's runs. + +For workflows that push their own bot commit, pass `sha:` with the last relevant source commit rather than the bot-updated HEAD. The key excludes floating toolchains, runner images, repository variables and other external inputs: use this only to validate the repository's own commit, not live health, latest dependencies or live-data reports. External changes without a commit need a manual dispatch. + +--- + ## Verifying release intent and release notes **`verify-pull-request-policy.yml`** combines release notes, warn-only release-note drift, From 4a0a9a6fd802b8d190cf33f9fa638b50850f4cde Mon Sep 17 00:00:00 2001 From: woksin Date: Sat, 24 Oct 2026 11:03:18 +0200 Subject: [PATCH 2/2] Run the skip test without a local work folder and trim event names The offline test created its temporary directory under the gitignored .ai-work folder, which a fresh CI checkout does not have. Event lists with spaces after the commas now match too. --- .github/actions/skip-if-already-ran/action.yml | 1 + .github/scripts/tests/skip-if-already-ran.test.py | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/actions/skip-if-already-ran/action.yml b/.github/actions/skip-if-already-ran/action.yml index d84266d..dd87c8c 100644 --- a/.github/actions/skip-if-already-ran/action.yml +++ b/.github/actions/skip-if-already-ran/action.yml @@ -58,6 +58,7 @@ runs: run_needed exit 0 fi + EVENTS="${EVENTS//[[:space:]]/}" case ",$EVENTS," in *",$EVENT_NAME,"*) ;; *) run_needed; exit 0 ;; diff --git a/.github/scripts/tests/skip-if-already-ran.test.py b/.github/scripts/tests/skip-if-already-ran.test.py index a6a82ca..0c237c6 100644 --- a/.github/scripts/tests/skip-if-already-ran.test.py +++ b/.github/scripts/tests/skip-if-already-ran.test.py @@ -45,7 +45,7 @@ def prior_run(): def execute(runs=None, *, missing_gh=False, response=None, **overrides): - with tempfile.TemporaryDirectory(dir=ROOT / ".ai-work") as temp: + with tempfile.TemporaryDirectory() as temp: directory = Path(temp) for name in ("output", "summary", "calls"): (directory / name).write_text("") @@ -138,6 +138,7 @@ def test_events_control_which_prior_runs_count(self): run["event"] = "push" self.assert_result(execute(runs=[run]), False) self.assert_result(execute(runs=[run], EVENTS="schedule,push"), True) + self.assert_result(execute(runs=[run], EVENTS="schedule, push"), True) def test_no_success_runs_again(self): self.assert_result(execute(), False)