diff --git a/.github/workflows/lint-workflows.yml b/.github/workflows/lint-workflows.yml new file mode 100644 index 00000000..9f356256 --- /dev/null +++ b/.github/workflows/lint-workflows.yml @@ -0,0 +1,82 @@ +name: Workflow lint + +# START OF COMMON SECTION +on: + push: + branches: [ 'master', 'main', 'release/**' ] + pull_request: + branches: [ '**' ] + types: [opened, synchronize, reopened, ready_for_review] + paths: + - '.github/workflows/**' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +# END OF COMMON SECTION + +permissions: + contents: read + +jobs: + reusable-concurrency: + if: github.event_name != 'pull_request' || github.event.pull_request.draft == false + name: Reusable workflows must not self-cancel + runs-on: ubuntu-22.04 + timeout-minutes: 5 + steps: + - name: Checkout wolfProvider + uses: actions/checkout@v4 + + - name: Install PyYAML + run: pip install --quiet pyyaml + + - name: Check concurrency in reusable workflows + run: | + python3 - <<'EOF' + import glob, sys, yaml + + paths = sorted(glob.glob('.github/workflows/*.yml') + + glob.glob('.github/workflows/*.yaml')) + bad = [] + for path in paths: + try: + with open(path) as f: + wf = yaml.safe_load(f) + except yaml.YAMLError as e: + print(f'{path}: unparsable: {e}') + sys.exit(1) + if not isinstance(wf, dict): + continue + # YAML 1.1 parses a bare `on:` key as the boolean True. + triggers = wf.get('on', wf.get(True)) or {} + # `on:` is legal as a mapping, a list, or a bare string. + if isinstance(triggers, str): + names = {triggers} + elif isinstance(triggers, list): + names = set(triggers) + elif isinstance(triggers, dict): + names = set(triggers) + else: + names = set() + if 'workflow_call' not in names: + continue + conc = wf.get('concurrency') or {} + if not isinstance(conc, dict): + continue + # Anything not literally false counts: "true"/${{ }} still cancel. + cip = conc.get('cancel-in-progress', False) + if cip is not False and str(cip).strip().lower() != 'false': + bad.append(f"{path}: cancel-in-progress={cip!r} " + f"group={conc.get('group')!r}") + + if bad: + print('Reusable workflow sets cancel-in-progress to something other') + print('than false. github.workflow resolves to the CALLER, so sibling') + print('calls in one run share a group and cancel each other. Set') + print('cancel-in-progress: false, or drop the concurrency block.') + for b in bad: + print(' ' + b) + sys.exit(1) + print('ok: no reusable workflow sets cancel-in-progress') + EOF diff --git a/.github/workflows/nightly-multi-compiler.yml b/.github/workflows/nightly-multi-compiler.yml index afcc3024..0552f1ef 100644 --- a/.github/workflows/nightly-multi-compiler.yml +++ b/.github/workflows/nightly-multi-compiler.yml @@ -12,9 +12,7 @@ on: workflow_call: {} workflow_dispatch: {} -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true +# No concurrency group: github.workflow is the caller's here, so any group collides with sibling calls. jobs: build_wolfprovider: diff --git a/.github/workflows/perf-regression.yml b/.github/workflows/perf-regression.yml index d218585e..2f44c0e2 100644 --- a/.github/workflows/perf-regression.yml +++ b/.github/workflows/perf-regression.yml @@ -6,9 +6,7 @@ on: workflow_dispatch: workflow_call: -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true +# No concurrency group: github.workflow is the caller's here, so any group collides with sibling calls. jobs: discover_versions: diff --git a/.github/workflows/pr-osp-retry.yml b/.github/workflows/pr-osp-retry.yml new file mode 100644 index 00000000..08ba84f4 --- /dev/null +++ b/.github/workflows/pr-osp-retry.yml @@ -0,0 +1,55 @@ +name: PR OSP retry + +# No concurrency group: workflow_run reports github.ref as the default branch, so any group self-cancels. + +on: + workflow_run: + workflows: ["PR OSP (label-selected)"] + types: [completed] + +permissions: + actions: write + +jobs: + retry: + name: Rerun failed jobs once + runs-on: ubuntu-latest + timeout-minutes: 5 + # 'failure' skips superseded runs (those land as 'cancelled'); attempt 1 bounds this to one retry. + if: > + github.event.workflow_run.conclusion == 'failure' && + github.event.workflow_run.run_attempt == 1 + steps: + - name: Verify trigger is trusted + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + ACTOR: ${{ github.event.workflow_run.actor.login }} + WF_PATH: ${{ github.event.workflow_run.path }} + run: | + # A fork controls both the workflow name and a new file's path, so pin the path. + if [ "$WF_PATH" != ".github/workflows/pr-osp-select.yml" ]; then + echo "::error::untrusted triggering workflow: $WF_PATH" + exit 1 + fi + # Distinguish "not a collaborator" (404) from an API failure; never conflate them. + if ! body=$(gh api "/repos/$REPO/collaborators/$ACTOR/permission" 2>&1); then + case "$body" in + *"Not Found"*) echo "::error::actor $ACTOR is not a collaborator"; exit 1 ;; + *) echo "::error::could not verify $ACTOR: $body"; exit 1 ;; + esac + fi + perm=$(printf '%s' "$body" | jq -r '.permission') + # triage can add labels, so it can legitimately start a PR OSP run. + case "$perm" in + admin|write|maintain|triage) echo "actor $ACTOR ok ($perm)" ;; + *) echo "::error::actor $ACTOR cannot label ($perm)"; exit 1 ;; + esac + + - name: Rerun failed jobs + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RUN_ID: ${{ github.event.workflow_run.id }} + run: | + gh api -X POST \ + "/repos/${{ github.repository }}/actions/runs/$RUN_ID/rerun-failed-jobs"