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..dd87c8c --- /dev/null +++ b/.github/actions/skip-if-already-ran/action.yml @@ -0,0 +1,90 @@ +# 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 + EVENTS="${EVENTS//[[:space:]]/}" + 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..0c237c6 --- /dev/null +++ b/.github/scripts/tests/skip-if-already-ran.test.py @@ -0,0 +1,167 @@ +# 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() 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) + 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,