diff --git a/.github/scripts/feature-ideation/lint-caller.sh b/.github/scripts/feature-ideation/lint-caller.sh new file mode 100755 index 000000000..eab96c1fe --- /dev/null +++ b/.github/scripts/feature-ideation/lint-caller.sh @@ -0,0 +1,151 @@ +#!/usr/bin/env bash +# lint-caller.sh — guard feature-ideation caller stubs against the #571 +# zero-job startup failure. +# +# Why this exists: +# A reusable-workflow call graph (`jobs..uses` + its `with:`) is validated +# at WORKFLOW SETUP — before, and regardless of, the calling job's `if:`. The +# `inputs` context is only populated for `workflow_dispatch` / `workflow_call` +# events, so a `with:` value that references `${{ inputs.* }}` fails the whole +# run (zero jobs, "Invalid workflow file") when the workflow is also triggered +# by an event that has no `inputs` — e.g. `discussion: created`. The job `if:` +# does NOT save you. This is petry-projects/.github#571. +# +# The fix is to resolve dispatch inputs in an ordinary `prep` job (whose step +# expressions run at job time and are skipped on `discussion`) and pass them to +# the reusable call via `needs.prep.outputs.*` — an always-valid context that +# defers the `with:` evaluation to run time. +# +# This linter flags any job-level reusable `uses:` whose `with:` references the +# `inputs` context. `github.event.inputs.*` and step-level action `uses:` are +# intentionally NOT flagged. +# +# Usage: +# lint-caller.sh [ ...] +# With no args, scans the two feature-ideation caller stubs. +# +# Exit codes: +# 0 no issues +# 1 one or more findings +# 2 bad usage / file or parse error + +set -euo pipefail + +scan_file() { + local file="$1" + + python3 - "$file" <<'PY' +import re +import sys + +try: + import yaml +except ImportError: + sys.stderr.write("[lint-caller] PyYAML is required (pip install pyyaml)\n") + sys.exit(2) + +path = sys.argv[1] +try: + with open(path, "r", encoding="utf-8") as f: + doc = yaml.safe_load(f) +except (OSError, yaml.YAMLError) as exc: + sys.stderr.write(f"[lint-caller] cannot parse {path}: {exc}\n") + sys.exit(2) + +if not isinstance(doc, dict): + sys.exit(0) + +jobs = doc.get("jobs") +if not isinstance(jobs, dict): + sys.exit(0) + +# `inputs` used as a context ROOT — i.e. `inputs.foo` or `inputs['foo']` — but +# NOT `github.event.inputs.foo` (where `inputs` is preceded by a `.`). The +# negative lookbehind excludes a leading `.` or word character. +inputs_ctx = re.compile(r"(?&2 + exit=2 + continue + fi + # Preserve exit-2 (file/parse error) over exit-1 (lint finding), matching + # lint-prompt.sh's precedence. + scan_file "$file" && file_rc=0 || file_rc=$? + case "$file_rc" in + 0) ;; + 1) if [ "$exit" -eq 0 ]; then exit=1; fi ;; + 2) exit=2 ;; + *) return "$file_rc" ;; + esac + done + return "$exit" +} + +main "$@" diff --git a/.github/workflows/feature-ideation-tests.yml b/.github/workflows/feature-ideation-tests.yml index eeac386b0..9bb808286 100644 --- a/.github/workflows/feature-ideation-tests.yml +++ b/.github/workflows/feature-ideation-tests.yml @@ -61,8 +61,8 @@ jobs: sudo apt-get update -qq sudo apt-get install -y --no-install-recommends bats shellcheck jq - - name: Install Python jsonschema - run: pip install --quiet 'jsonschema>=4' + - name: Install Python jsonschema and PyYAML + run: pip install --quiet 'jsonschema>=4' 'pyyaml>=6' - name: shellcheck run: | @@ -71,6 +71,7 @@ jobs: shellcheck -x \ collect-signals.sh \ lint-prompt.sh \ + lint-caller.sh \ match-discussions.sh \ discussion-mutations.sh \ lib/gh-safe.sh \ @@ -81,6 +82,9 @@ jobs: - name: Lint direct prompt blocks run: bash .github/scripts/feature-ideation/lint-prompt.sh + - name: Lint caller stubs for the #571 inputs-context startup failure + run: bash .github/scripts/feature-ideation/lint-caller.sh + - name: Validate schema fixtures run: | set -euo pipefail diff --git a/.github/workflows/feature-ideation.yml b/.github/workflows/feature-ideation.yml index 96d80705d..0d7e5426c 100644 --- a/.github/workflows/feature-ideation.yml +++ b/.github/workflows/feature-ideation.yml @@ -1,19 +1,44 @@ # ───────────────────────────────────────────────────────────────────────────── # SOURCE OF TRUTH: petry-projects/.github/standards/workflows/feature-ideation.yml -# Standard: petry-projects/.github/standards/ci-standards.md#8 +# Standard: petry-projects/.github/standards/ci-standards.md#8-feature-ideation-feature-ideationyml--bmad-method-repos # Reusable: petry-projects/.github/.github/workflows/feature-ideation-reusable.yml # # AGENTS — READ BEFORE EDITING: # • This file is a THIN CALLER STUB. The 5-phase ideation pipeline, the # Opus 4.6 model selection, the github_token override, and the # ANTHROPIC_MODEL env var all live in the reusable workflow above. -# • You MAY change: the `project_context` value and optionally the cron -# schedule. +# • You MAY change: the `project_context` value (the only required edit +# per repo), and optionally the cron schedule. # • You MUST NOT change: trigger event shape, the `uses:` line, the -# job-level `permissions:` block, or the `secrets:` block. +# job-level `permissions:` block, or the `secrets:` block — these are +# required for the reusable to work. # • If you need different behaviour, open a PR against the reusable in -# this repo. The change will propagate after the v1 tag is bumped. +# the central repo. The change will propagate everywhere on next run. # ───────────────────────────────────────────────────────────────────────────── +# +# Feature Ideation workflow stub — for BMAD Method-enabled repos. +# +# This is a thin caller for the org-wide reusable workflow at +# petry-projects/.github/.github/workflows/feature-ideation-reusable.yml +# All ideation logic, the multi-skill pipeline, the Opus 4.6 model +# selection, and the github_token override live in the reusable workflow. +# +# To adopt: +# 1. Copy this file to .github/workflows/feature-ideation.yml in your repo. +# 2. Replace the `project_context` value with a 3-5 sentence description +# of your project, its target users, and the competitive landscape Mary +# should research. This is the only required customisation. +# 3. (Optional) Copy standards/feature-ideation-sources.md from +# petry-projects/.github to .github/feature-ideation-sources.md in your +# repo and trim/extend it for your project. Mary uses YOUR copy — not the +# central template — so each repo controls its own source list. +# Pass `sources_file: path/to/your-list.md` to the reusable workflow if +# you prefer a different location. +# 4. (Optional) Adjust the schedule cron if Friday morning UTC doesn't suit. +# 5. Ensure GitHub Discussions is enabled with an "Ideas" category. +# 6. Confirm the org-level secret CLAUDE_CODE_OAUTH_TOKEN is accessible. +# +# Standard: https://github.com/petry-projects/.github/blob/main/standards/ci-standards.md#8-feature-ideation-feature-ideationyml--bmad-method-repos name: Feature Research & Ideation (BMAD Analyst) on: @@ -22,7 +47,7 @@ on: workflow_dispatch: inputs: focus_area: - description: 'Optional focus area (e.g., "CI patterns", "agent security")' + description: 'Optional focus area (e.g., "accessibility", "performance")' required: false type: string research_depth: @@ -35,12 +60,23 @@ on: - standard - deep dry_run: - description: 'Log Discussion mutations to artifact instead of writing live' + description: 'Skip Discussion mutations and log them to a JSONL artifact instead. Use this on a fork to smoke-test before going live.' required: false default: false type: boolean + enhance_backlog: + description: 'Backlog enhancement sweep: enhance this repo''s open, human-authored, not-yet-enhanced Ideas (one comment each, idempotent). Combine with dry_run=true to preview.' + required: false + default: false + type: boolean + target_discussion: + description: 'Internal — set by `redispatch` when bridging a `discussion:created` event to workflow_dispatch (claude-code-action does not support discussion contexts). Not for manual use.' + required: false + default: '' + type: string # Auto-enhance a freshly-created idea: when a new Discussion is opened in the - # Ideas category, the analyst researches and refines it in single-idea mode. + # Ideas category, the `redispatch` job bridges it to workflow_dispatch (see below) + # so the analyst refines it in single-idea mode under a supported event. discussion: types: [created] @@ -51,36 +87,109 @@ concurrency: cancel-in-progress: false jobs: - ideate: - # On the `discussion` trigger, only run for new ideas in the Ideas category - # and skip the bot's own creations (avoids re-enhancing scheduled output; - # enhancement posts a comment, which does not re-fire `discussion: created`). - # Schedule/dispatch runs are unaffected by this guard. + # ── #963 discussion→dispatch redispatch bridge ─────────────────────────────── + # claude-code-action aborts on `discussion` event contexts ("Unsupported event + # type: discussion"). So on `discussion: created` we do NOT call the reusable + # inline; the `redispatch` job re-invokes this workflow via workflow_dispatch + # (under which the action runs cleanly), passing the number as target_discussion. + # Mirrors initiative-planner.yml's redispatch (#618). The `ideate` reusable call + # then runs only on workflow_dispatch/schedule. + redispatch: if: >- - github.event_name != 'discussion' || - (github.event.discussion.category.slug == 'ideas' && - github.event.discussion.user.type != 'Bot') + github.event_name == 'discussion' && + github.event.discussion.category.slug == 'ideas' && + github.event.discussion.user.type != 'Bot' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + steps: + - name: Guard — PAT present + # A workflow_dispatch fired with GITHUB_TOKEN is accepted but never starts + # a run (loop prevention), so a PAT is required (same as initiative-planner). + env: + GH_TOKEN: ${{ secrets.GH_PAT_WORKFLOWS }} + run: | + if [ -z "${GH_TOKEN}" ]; then + echo "::error::GH_PAT_WORKFLOWS is required — a workflow_dispatch fired with GITHUB_TOKEN will not start a run." + exit 1 + fi + - name: Re-dispatch under workflow_dispatch + env: + GH_TOKEN: ${{ secrets.GH_PAT_WORKFLOWS }} + REPO: ${{ github.repository }} + DISCUSSION_NUMBER: ${{ github.event.discussion.number }} + run: | + gh workflow run feature-ideation.yml --repo "${REPO}" \ + -f target_discussion="${DISCUSSION_NUMBER}" + + # ── #571 input resolution — keep `inputs` out of the reusable `with:` ──────── + # A reusable-workflow call graph (its `uses:` + `with:`) is validated at + # WORKFLOW SETUP, before — and regardless of — the calling job's `if:`. The + # `inputs` context is only populated for workflow_dispatch/workflow_call, so a + # `with:` that references `${{ inputs.* }}` trips a zero-job startup failure on + # the `discussion` event even though `ideate` is gated off it. We therefore + # resolve the dispatch inputs in this ordinary job (whose step expressions are + # evaluated at RUN time and are skipped on `discussion`) and hand them to + # `ideate` via `needs.prep.outputs.*` — an always-valid context that defers the + # `with:` evaluation to run time. Do not reference `inputs` in `ideate.with`. + prep: + if: github.event_name != 'discussion' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + outputs: + target_discussion: ${{ steps.resolve.outputs.target_discussion }} + focus_area: ${{ steps.resolve.outputs.focus_area }} + research_depth: ${{ steps.resolve.outputs.research_depth }} + dry_run: ${{ steps.resolve.outputs.dry_run }} + enhance_backlog: ${{ steps.resolve.outputs.enhance_backlog }} + steps: + - name: Resolve dispatch inputs + id: resolve + # `github.event.inputs.*` (not the `inputs` context) so this reads + # cleanly on schedule too, where it is simply null → the defaults below. + env: + TARGET_DISCUSSION: ${{ github.event.inputs.target_discussion }} + FOCUS_AREA: ${{ github.event.inputs.focus_area }} + RESEARCH_DEPTH: ${{ github.event.inputs.research_depth }} + DRY_RUN: ${{ github.event.inputs.dry_run }} + ENHANCE_BACKLOG: ${{ github.event.inputs.enhance_backlog }} + run: | + { + echo "target_discussion=${TARGET_DISCUSSION:-}" + echo "focus_area=${FOCUS_AREA:-}" + echo "research_depth=${RESEARCH_DEPTH:-standard}" + echo "dry_run=${DRY_RUN:-false}" + echo "enhance_backlog=${ENHANCE_BACKLOG:-false}" + } >> "$GITHUB_OUTPUT" + + ideate: + # Runs on workflow_dispatch (including the redispatch bridge above) and schedule. + needs: [prep] + if: github.event_name != 'discussion' # Permissions cascade from the calling job to the reusable workflow. # The reusable workflow's two jobs (gather-signals + analyze) need: # - contents: read (checkout, file reads) # - issues: read (signal collection) # - pull-requests: read (signal collection) # - discussions: write (CRITICAL — create/update Discussion threads) - # - actions: read (feed checkpoint via gh run list) # - id-token: write (claude-code-action OIDC for GitHub App token) + # - actions: read (feed checkpoint — last successful run query) permissions: contents: read issues: read pull-requests: read discussions: write - actions: read id-token: write + actions: read uses: petry-projects/.github/.github/workflows/feature-ideation-reusable.yml@3500ed2675d2ac60c8521f295ffbeba75434b953 # v1 with: - # On the `discussion: created` trigger this carries the new Discussion's - # number → the reusable runs single-idea enhancement mode. Empty on - # schedule/dispatch → normal scan. - target_discussion: ${{ github.event.discussion.number }} + # All values below come from `needs.prep.outputs.*` (resolved in the `prep` + # job) — NOT the `inputs` context — so this reusable `with:` compiles on the + # `discussion` event without a zero-job startup failure (#571). + # `target_discussion` is forwarded by the `redispatch` bridge above on + # `discussion: created`; empty on schedule/dispatch → normal scan. + target_discussion: ${{ needs.prep.outputs.target_discussion }} project_context: | petry-projects/.github is the org-level standards and tooling repository for the petry-projects GitHub organisation. It owns the canonical CI/CD @@ -97,8 +206,12 @@ jobs: scopes), and developer-experience standards for LLM-heavy codebases. No public users — this is internal DevX infrastructure. sources_file: 'standards/feature-ideation-sources.md' - focus_area: ${{ inputs.focus_area || '' }} - research_depth: ${{ inputs.research_depth || 'standard' }} - dry_run: ${{ inputs.dry_run || false }} + focus_area: ${{ needs.prep.outputs.focus_area }} + research_depth: ${{ needs.prep.outputs.research_depth }} + # prep emits the string 'true'/'false'; fromJSON casts it to the boolean + # the reusable's typed inputs require. + dry_run: ${{ fromJSON(needs.prep.outputs.dry_run) }} + # Backlog enhancement sweep of existing un-enhanced Ideas (workflow_dispatch). + enhance_backlog: ${{ fromJSON(needs.prep.outputs.enhance_backlog) }} secrets: CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} diff --git a/standards/ci-standards.md b/standards/ci-standards.md index 7d1a33ea3..383716bae 100644 --- a/standards/ci-standards.md +++ b/standards/ci-standards.md @@ -963,13 +963,29 @@ is a separate Discussion, updated by subsequent runs as the market and project evolve. **Triggers:** the weekly `schedule`, manual `workflow_dispatch`, **and -`discussion: created`**. On the discussion trigger, the stub passes -`target_discussion: ${{ github.event.discussion.number }}`, putting the reusable -in **single-idea enhancement mode**: it researches and refines that one new idea -and posts a single enhancement comment, rather than running the broad scan. A -job-level `if` restricts this to new Discussions in the **Ideas** category and -skips the bot's own creations; enhancement is a comment (which does not re-fire -`created`), so there is no trigger loop. +`discussion: created`**. `claude-code-action` aborts on `discussion` event +contexts, so the stub does **not** call the reusable inline on that event. +Instead a `redispatch` job — gated to new Discussions in the **Ideas** category, +skipping the bot's own creations — re-invokes the workflow via +`workflow_dispatch` (using `GH_PAT_WORKFLOWS`), forwarding the Discussion number +as the `target_discussion` input. The re-dispatched run puts the reusable in +**single-idea enhancement mode**: it researches and refines that one new idea and +posts a single enhancement comment, rather than running the broad scan. +Enhancement is a comment (which does not re-fire `created`), so there is no +trigger loop. This mirrors `initiative-planner.yml`'s redispatch bridge. + +> **#571 — never reference the `inputs` context in the reusable `with:`.** A +> reusable-workflow call graph (`uses:` + `with:`) is validated at **workflow +> setup**, before and regardless of the calling job's `if:`. The `inputs` +> context is only populated for `workflow_dispatch` / `workflow_call`, so a +> `with:` value referencing `${{ inputs.* }}` fails the whole run (zero jobs, +> "Invalid workflow file") on the `discussion` trigger even though the `ideate` +> job is gated off it. The stub therefore resolves dispatch inputs in an +> ordinary `prep` job (whose step expressions run at job time and are skipped on +> `discussion`) and passes them to `ideate` via `needs.prep.outputs.*` — an +> always-valid context that defers the `with:` evaluation to run time. This is +> enforced by [`lint-caller.sh`](../.github/scripts/feature-ideation/lint-caller.sh) +> in the feature-ideation test suite. **Backlog enhancement (backfill) + dry-run.** Beyond enhancing *newly-created* Ideas, the reusable can **backfill the existing Ideas backlog**: dispatch with diff --git a/standards/workflows/feature-ideation.yml b/standards/workflows/feature-ideation.yml index c7ed75fea..f5ccbba78 100644 --- a/standards/workflows/feature-ideation.yml +++ b/standards/workflows/feature-ideation.yml @@ -122,8 +122,50 @@ jobs: gh workflow run feature-ideation.yml --repo "${REPO}" \ -f target_discussion="${DISCUSSION_NUMBER}" + # ── #571 input resolution — keep `inputs` out of the reusable `with:` ──────── + # A reusable-workflow call graph (its `uses:` + `with:`) is validated at + # WORKFLOW SETUP, before — and regardless of — the calling job's `if:`. The + # `inputs` context is only populated for workflow_dispatch/workflow_call, so a + # `with:` that references `${{ inputs.* }}` trips a zero-job startup failure on + # the `discussion` event even though `ideate` is gated off it. We therefore + # resolve the dispatch inputs in this ordinary job (whose step expressions are + # evaluated at RUN time and are skipped on `discussion`) and hand them to + # `ideate` via `needs.prep.outputs.*` — an always-valid context that defers the + # `with:` evaluation to run time. Do not reference `inputs` in `ideate.with`. + prep: + if: github.event_name != 'discussion' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + outputs: + target_discussion: ${{ steps.resolve.outputs.target_discussion }} + focus_area: ${{ steps.resolve.outputs.focus_area }} + research_depth: ${{ steps.resolve.outputs.research_depth }} + dry_run: ${{ steps.resolve.outputs.dry_run }} + enhance_backlog: ${{ steps.resolve.outputs.enhance_backlog }} + steps: + - name: Resolve dispatch inputs + id: resolve + # `github.event.inputs.*` (not the `inputs` context) so this reads + # cleanly on schedule too, where it is simply null → the defaults below. + env: + TARGET_DISCUSSION: ${{ github.event.inputs.target_discussion }} + FOCUS_AREA: ${{ github.event.inputs.focus_area }} + RESEARCH_DEPTH: ${{ github.event.inputs.research_depth }} + DRY_RUN: ${{ github.event.inputs.dry_run }} + ENHANCE_BACKLOG: ${{ github.event.inputs.enhance_backlog }} + run: | + { + echo "target_discussion=${TARGET_DISCUSSION:-}" + echo "focus_area=${FOCUS_AREA:-}" + echo "research_depth=${RESEARCH_DEPTH:-standard}" + echo "dry_run=${DRY_RUN:-false}" + echo "enhance_backlog=${ENHANCE_BACKLOG:-false}" + } >> "$GITHUB_OUTPUT" + ideate: # Runs on workflow_dispatch (including the redispatch bridge above) and schedule. + needs: [prep] if: github.event_name != 'discussion' # Permissions cascade from the calling job to the reusable workflow. # The reusable workflow's two jobs (gather-signals + analyze) need: @@ -142,10 +184,12 @@ jobs: actions: read uses: petry-projects/.github/.github/workflows/feature-ideation-reusable.yml@e20a8fac1b6a10bd6ad0999258e88a423f890ba6 # v1 with: - # Populated by the `redispatch` bridge above (which forwards the new - # Discussion number on `discussion: created`); empty on schedule/dispatch → - # normal scan. Do not edit this line. - target_discussion: ${{ inputs.target_discussion || '' }} + # All values below come from `needs.prep.outputs.*` (resolved in the `prep` + # job) — NOT the `inputs` context — so this reusable `with:` compiles on the + # `discussion` event without a zero-job startup failure (#571). + # `target_discussion` is forwarded by the `redispatch` bridge above on + # `discussion: created`; empty on schedule/dispatch → normal scan. + target_discussion: ${{ needs.prep.outputs.target_discussion }} # === CUSTOMISE THIS PER REPO — the only required edit === # Replace this paragraph with a 3-5 sentence description of your project, # its target users, and the competitive landscape. The more specific you @@ -160,10 +204,12 @@ jobs: # workflow defaults to that path, so you only need to uncomment and change # sources_file below if you store the list somewhere else. # sources_file: 'docs/feature-ideation-sources.md' - focus_area: ${{ inputs.focus_area || '' }} - research_depth: ${{ inputs.research_depth || 'standard' }} - dry_run: ${{ inputs.dry_run || false }} + focus_area: ${{ needs.prep.outputs.focus_area }} + research_depth: ${{ needs.prep.outputs.research_depth }} + # prep emits the string 'true'/'false'; fromJSON casts it to the boolean + # the reusable's typed inputs require. + dry_run: ${{ fromJSON(needs.prep.outputs.dry_run) }} # Backlog enhancement sweep of existing un-enhanced Ideas (workflow_dispatch). - enhance_backlog: ${{ inputs.enhance_backlog == true }} + enhance_backlog: ${{ fromJSON(needs.prep.outputs.enhance_backlog) }} secrets: CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} diff --git a/test/workflows/feature-ideation/fixtures/callers/clean-needs-outputs.yml b/test/workflows/feature-ideation/fixtures/callers/clean-needs-outputs.yml new file mode 100644 index 000000000..0d9f5d548 --- /dev/null +++ b/test/workflows/feature-ideation/fixtures/callers/clean-needs-outputs.yml @@ -0,0 +1,35 @@ +# Fixture: the #571-correct shape — dispatch inputs resolved in a `prep` job and +# passed to the reusable via `needs.prep.outputs.*`. No `inputs` context in the +# reusable `with:`. lint-caller.sh must pass this (exit 0). +name: clean caller +on: + schedule: + - cron: '0 7 * * 5' + workflow_dispatch: + inputs: + focus_area: + required: false + type: string + discussion: + types: [created] + +jobs: + prep: + if: github.event_name != 'discussion' + runs-on: ubuntu-latest + outputs: + focus_area: ${{ steps.resolve.outputs.focus_area }} + steps: + - id: resolve + env: + FOCUS_AREA: ${{ github.event.inputs.focus_area }} + run: echo "focus_area=${FOCUS_AREA:-}" >> "$GITHUB_OUTPUT" + + ideate: + needs: [prep] + if: github.event_name != 'discussion' + uses: org/.github/.github/workflows/reusable.yml@v1 + with: + project_context: | + A literal string with no expressions. + focus_area: ${{ needs.prep.outputs.focus_area }} diff --git a/test/workflows/feature-ideation/fixtures/callers/inputs-in-with.yml b/test/workflows/feature-ideation/fixtures/callers/inputs-in-with.yml new file mode 100644 index 000000000..82882c46e --- /dev/null +++ b/test/workflows/feature-ideation/fixtures/callers/inputs-in-with.yml @@ -0,0 +1,24 @@ +# Fixture: the #571-broken shape — the reusable `with:` references the `inputs` +# context. The job `if:` does NOT prevent the reusable call graph from being +# validated at workflow setup, so on the `discussion` trigger this fails the +# whole run with zero jobs. lint-caller.sh must flag this (exit 1). +name: broken caller +on: + workflow_dispatch: + inputs: + focus_area: + required: false + type: string + target_discussion: + required: false + type: string + discussion: + types: [created] + +jobs: + ideate: + if: github.event_name != 'discussion' + uses: org/.github/.github/workflows/reusable.yml@v1 + with: + target_discussion: ${{ inputs.target_discussion || '' }} + focus_area: ${{ inputs.focus_area || '' }} diff --git a/test/workflows/feature-ideation/lint-caller.bats b/test/workflows/feature-ideation/lint-caller.bats new file mode 100644 index 000000000..f9f6f8200 --- /dev/null +++ b/test/workflows/feature-ideation/lint-caller.bats @@ -0,0 +1,127 @@ +#!/usr/bin/env bats +# Tests for lint-caller.sh — kills #571 (a reusable-workflow `with:` that +# references the `inputs` context, which fails at workflow setup with zero jobs +# on the `discussion` trigger regardless of the calling job's `if:`). + +load 'helpers/setup' + +setup() { + tt_make_tmpdir +} + +teardown() { + tt_cleanup_tmpdir +} + +LINTER="${TT_REPO_ROOT}/.github/scripts/feature-ideation/lint-caller.sh" +CALLERS="${TT_FIXTURES_DIR}/callers" + +write_yml() { + local path="$1" + cat >"$path" +} + +# --------------------------------------------------------------------------- +# Committed fixtures — the correct and the broken shapes. +# --------------------------------------------------------------------------- + +@test "lint-caller: clean needs.prep.outputs caller passes" { + run bash "$LINTER" "${CALLERS}/clean-needs-outputs.yml" + [ "$status" -eq 0 ] +} + +@test "lint-caller: FAILS on inputs.* in a reusable with: block" { + run bash "$LINTER" "${CALLERS}/inputs-in-with.yml" + [ "$status" -eq 1 ] + # The offending key must be named in the output. + [[ "$output" == *"with.target_discussion"* ]] || [[ "$output" == *"with.focus_area"* ]] +} + +# --------------------------------------------------------------------------- +# False-positive guards. +# --------------------------------------------------------------------------- + +@test "lint-caller: step-level action uses: with inputs is NOT flagged" { + write_yml "${TT_TMP}/steplevel.yml" <<'YML' +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v7 + with: + script: core.info('${{ inputs.focus_area }}') +YML + run bash "$LINTER" "${TT_TMP}/steplevel.yml" + [ "$status" -eq 0 ] +} + +@test "lint-caller: github.event.inputs.* in a reusable with: is allowed" { + # github.event.inputs is available at setup for every event (null when absent), + # so it does not cause the #571 startup failure and must not be flagged. + write_yml "${TT_TMP}/ghevent.yml" <<'YML' +jobs: + ideate: + uses: org/.github/.github/workflows/reusable.yml@v1 + with: + focus_area: ${{ github.event.inputs.focus_area }} +YML + run bash "$LINTER" "${TT_TMP}/ghevent.yml" + [ "$status" -eq 0 ] +} + +@test "lint-caller: a job without a reusable uses: is ignored" { + write_yml "${TT_TMP}/nouses.yml" <<'YML' +jobs: + prep: + runs-on: ubuntu-latest + steps: + - run: echo "${{ inputs.focus_area }}" +YML + run bash "$LINTER" "${TT_TMP}/nouses.yml" + [ "$status" -eq 0 ] +} + +@test "lint-caller: inputs referenced via index syntax is flagged" { + write_yml "${TT_TMP}/index.yml" <<'YML' +jobs: + ideate: + uses: org/.github/.github/workflows/reusable.yml@v1 + with: + focus_area: ${{ inputs['focus_area'] }} +YML + run bash "$LINTER" "${TT_TMP}/index.yml" + [ "$status" -eq 1 ] +} + +# --------------------------------------------------------------------------- +# Regression guards — the live caller stubs must stay clean. +# --------------------------------------------------------------------------- + +@test "lint-caller: standards caller-stub template lints clean" { + workflow="${TT_REPO_ROOT}/standards/workflows/feature-ideation.yml" + [ -f "$workflow" ] + run bash "$LINTER" "$workflow" + [ "$status" -eq 0 ] +} + +@test "lint-caller: this repo's own caller lints clean" { + workflow="${TT_REPO_ROOT}/.github/workflows/feature-ideation.yml" + [ -f "$workflow" ] + run bash "$LINTER" "$workflow" + [ "$status" -eq 0 ] +} + +@test "lint-caller: default (no args) scans both caller stubs and passes" { + run bash "$LINTER" + [ "$status" -eq 0 ] +} + +# --------------------------------------------------------------------------- +# Exit-code precedence: a file/parse error (2) must not be downgraded by a +# later lint finding (1). Mirrors lint-prompt.sh. +# --------------------------------------------------------------------------- + +@test "lint-caller: missing file before a lint-failing file still exits 2" { + run bash "$LINTER" "${TT_TMP}/missing.yml" "${CALLERS}/inputs-in-with.yml" + [ "$status" -eq 2 ] +}