diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa634fd49..d3ec5df32 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,7 +81,8 @@ jobs: rm actionlint.tar.gz # Lint our own workflow files (fail on errors, ignore shellcheck style hints) # `job_workflow_sha` is a valid reusable-workflow context property; actionlint 1.7.7 schema omits it - ./actionlint -ignore 'SC2129' -ignore '"job_workflow_sha" is not defined' .github/workflows/*.yml + # `job.workflow_sha` likewise (the reusable's own commit; #839) — actionlint's job schema omits it + ./actionlint -ignore 'SC2129' -ignore '"job_workflow_sha" is not defined' -ignore 'property "workflow_sha" is not defined in object type' .github/workflows/*.yml # Template workflows use placeholder expressions — informational only ./actionlint -ignore 'context' standards/workflows/*.yml || true diff --git a/.github/workflows/feature-ideation-reusable.yml b/.github/workflows/feature-ideation-reusable.yml index 0e939f9c0..e1629ad4d 100644 --- a/.github/workflows/feature-ideation-reusable.yml +++ b/.github/workflows/feature-ideation-reusable.yml @@ -105,16 +105,21 @@ on: tooling_ref: description: | Ref of combsmith/.github to source the feature-ideation scripts from. - Defaults to `v1` to align with the @v1 pin convention used by caller stubs - (see standards/ci-standards.md tier model). When the v1 tag is moved - forward to a new release, callers automatically pick up the matching - script + schema versions in lockstep with the workflow file. + Leave empty (the default) to source them from THIS reusable's own + commit (`job.workflow_sha`), so the scripts, schema and + requirements file always match the channel the caller pinned + (e.g. `@feature-ideation/v1-stable`), with no skew. (Previously + defaulted to `v1`, which predates + scripts/feature-ideation-requirements.txt and made every fleet run + fail at "Install Python jsonschema" — see issue #839.) + Not `github.job_workflow_sha`: it evaluates empty at step level, + which makes actions/checkout silently fall back to `main`. Override only for: - Testing a fork or PR branch end-to-end (`tooling_ref: my-feature-branch`) - Bleeding-edge testing against the latest main (`tooling_ref: main`) required: false - default: 'v1' + default: '' type: string sources_file: description: | @@ -162,7 +167,8 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: combsmith/.github - ref: ${{ inputs.tooling_ref }} + # This reusable's own commit unless overridden — see `tooling_ref` (#839). + ref: ${{ inputs.tooling_ref || job.workflow_sha }} path: .feature-ideation-tooling fetch-depth: 1 @@ -219,7 +225,8 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: combsmith/.github - ref: ${{ inputs.tooling_ref }} + # This reusable's own commit unless overridden — see `tooling_ref` (#839). + ref: ${{ inputs.tooling_ref || job.workflow_sha }} path: .feature-ideation-tooling fetch-depth: 1 diff --git a/test/workflows/feature-ideation/tooling-ref.bats b/test/workflows/feature-ideation/tooling-ref.bats new file mode 100644 index 000000000..83ab7ef6e --- /dev/null +++ b/test/workflows/feature-ideation/tooling-ref.bats @@ -0,0 +1,71 @@ +#!/usr/bin/env bats +# Regression guard for issue #839: the tooling checkout must track the +# reusable's own commit, never a floating tag. +# +# The reusable checks out combsmith/.github into .feature-ideation-tooling and +# runs scripts from it. When `tooling_ref` defaulted to `v1`, the channel tags +# (feature-ideation/v1-*) advanced the reusable to a version whose pip step +# needs scripts/feature-ideation-requirements.txt, while the tooling checkout +# stayed on the older `v1` tag that lacks it — every fleet run failed. The +# reusable and its tooling must come from the same commit (`job.workflow_sha`). +# +# Not `github.job_workflow_sha`: it evaluates empty at step level, so +# actions/checkout silently falls back to the default branch (`main`) — +# observed in a live run, and in the job-context probe for #839. + +load 'helpers/setup' + +REUSABLE="${TT_REPO_ROOT}/.github/workflows/feature-ideation-reusable.yml" +STUB="${TT_REPO_ROOT}/standards/workflows/feature-ideation.yml" +SELF_CALLER="${TT_REPO_ROOT}/.github/workflows/feature-ideation.yml" + +EXPECTED_REF='${{ inputs.tooling_ref || job.workflow_sha }}' + +@test "reusable: tooling_ref defaults to empty (no floating tag)" { + run yq -r '.on.workflow_call.inputs.tooling_ref.default' "$REUSABLE" + [ "$status" -eq 0 ] + [ "$output" = "" ] +} + +@test "reusable: every tooling checkout falls back to the reusable's own commit" { + for job in gather-signals analyze; do + run yq -r ".jobs.\"${job}\".steps[] | select(.with.path == \".feature-ideation-tooling\") | .with.ref" "$REUSABLE" + [ "$status" -eq 0 ] + [ -n "$output" ] || { echo "$job: no tooling checkout step"; return 1; } + while IFS= read -r ref; do + [ -n "$ref" ] || { echo "$job: tooling checkout ref is empty"; return 1; } + [ "$ref" = "$EXPECTED_REF" ] || { echo "$job: tooling checkout ref drifted to $ref (expected $EXPECTED_REF)"; return 1; } + done <<< "$output" + done +} + +@test "reusable: never relies on github.job_workflow_sha (empty at step level)" { + run grep -nE '\$\{\{[^}]*github\.job_workflow_sha' "$REUSABLE" + [ "$status" -eq 1 ] +} + +@test "reusable: every tooling checkout targets combsmith/.github" { + run yq -r '.jobs[].steps[] | select(.with.path == ".feature-ideation-tooling") | .with.repository' "$REUSABLE" + [ "$status" -eq 0 ] + [ -n "$output" ] + ! echo "$output" | grep -vxF 'combsmith/.github' +} + +@test "reusable: every .feature-ideation-tooling path it reads exists in this commit" { + # The tooling checkout is this same commit, so each referenced file must be + # here — a step that reads a file the commit lacks is the #839 failure mode. + run grep -oE '\.feature-ideation-tooling/[A-Za-z0-9._/-]+\.(sh|py|txt|json)' "$REUSABLE" + [ "$status" -eq 0 ] + [ -n "$output" ] + while IFS= read -r p; do + [ -f "${TT_REPO_ROOT}/${p#.feature-ideation-tooling/}" ] || { echo "missing: $p"; return 1; } + done <<< "$(echo "$output" | sort -u)" +} + +@test "callers: no caller pins tooling_ref to a ref (that would reintroduce skew)" { + for f in "$STUB" "$SELF_CALLER"; do + run yq -r '[.jobs[] | select(.with != null) | .with | has("tooling_ref")] | any' "$f" + [ "$status" -eq 0 ] + [ "$output" = "false" ] || { echo "$f forwards tooling_ref"; return 1; } + done +}