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
90 changes: 90 additions & 0 deletions .github/actions/skip-if-already-ran/action.yml
Original file line number Diff line number Diff line change
@@ -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"
167 changes: 167 additions & 0 deletions .github/scripts/tests/skip-if-already-ran.test.py
Original file line number Diff line number Diff line change
@@ -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()
56 changes: 56 additions & 0 deletions .github/workflows/verify-skip-if-already-ran.yml
Original file line number Diff line number Diff line change
@@ -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"
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -725,6 +725,35 @@ The key is `<key-prefix>-<runner.os>-<hashFiles(<working-directory>/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.<gate>.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,
Expand Down
Loading