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
24 changes: 15 additions & 9 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@ env:
# Concurrency is intentionally job-scoped. Superseded read-only jobs cancel
# independently away from main. Jobs on main are never superseded, which lets
# the terminal status gate reliably treat a cancellation there as a timeout or
# manual cancellation. Write-capable release jobs share one FIFO queue.
# manual cancellation. Write-capable release jobs share one non-cancelling
# concurrency group: a write that has started is never interrupted, and GitHub
# holds at most one pending run for the group. GitHub Actions accepts only
# `group` and `cancel-in-progress` here -- there is no queue-depth key.
jobs:
# === DETECT CHANGES - determines which jobs should run ===
detect-changes:
Expand Down Expand Up @@ -489,7 +492,6 @@ jobs:
concurrency:
group: ${{ github.workflow }}-main-write
cancel-in-progress: false
queue: max
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
timeout-minutes: 30
Expand Down Expand Up @@ -544,7 +546,7 @@ jobs:

# Get current version from pyproject.toml
CURRENT_VERSION=$(grep -Po '(?<=^version = ")[^"]*' "$PYPROJECT")
echo "current_version=$CURRENT_VERSION" >> $GITHUB_OUTPUT
echo "current_version=$CURRENT_VERSION" >> "$GITHUB_OUTPUT"

if [ "${{ steps.python_layout.outputs.multi_language }}" = "true" ]; then
TAG="py_v$CURRENT_VERSION"
Expand All @@ -556,10 +558,10 @@ jobs:
# Check if tag exists
if git rev-parse "$TAG" >/dev/null 2>&1; then
echo "Tag $TAG already exists, skipping release"
echo "should_release=false" >> $GITHUB_OUTPUT
echo "should_release=false" >> "$GITHUB_OUTPUT"
else
echo "New version detected: $CURRENT_VERSION ($TAG)"
echo "should_release=true" >> $GITHUB_OUTPUT
echo "should_release=true" >> "$GITHUB_OUTPUT"
fi

- name: Download artifacts
Expand Down Expand Up @@ -603,7 +605,6 @@ jobs:
concurrency:
group: ${{ github.workflow }}-main-write
cancel-in-progress: false
queue: max
# !cancelled() prevents the skipped detect-changes dependency from propagating
# through lint/test/build while still propagating workflow cancellation.
# The explicit result checks ensure release only proceeds after CI passes.
Expand Down Expand Up @@ -765,9 +766,11 @@ jobs:
echo "::notice::Skipping Docker publish because Docker Hub is not configured"
echo "enabled=false" >> "$GITHUB_OUTPUT"
else
echo "enabled=true" >> "$GITHUB_OUTPUT"
echo "image=$DOCKERHUB_IMAGE" >> "$GITHUB_OUTPUT"
echo "version=$RELEASE_VERSION" >> "$GITHUB_OUTPUT"
{
echo "enabled=true"
echo "image=$DOCKERHUB_IMAGE"
echo "version=$RELEASE_VERSION"
} >> "$GITHUB_OUTPUT"
fi

# Native runners build both architectures in parallel and push immutable
Expand Down Expand Up @@ -857,6 +860,9 @@ jobs:
IMAGE: ${{ needs.docker-publish-config.outputs.image }}
VERSION: ${{ needs.docker-publish-config.outputs.version }}
run: |
# Word splitting is deliberate: every digest file in this directory
# becomes its own source argument for the manifest.
# shellcheck disable=SC2046
docker buildx imagetools create \
--tag "${IMAGE}:latest" \
--tag "${IMAGE}:${VERSION}" \
Expand Down
42 changes: 42 additions & 0 deletions .github/workflows/workflows.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: Workflows

# GitHub Actions ignores unknown workflow keys instead of rejecting them, so a
# typo such as `queue: max` inside a `concurrency:` block silently does nothing
# (issue #62). actionlint validates the schema and, through shellcheck, every
# `run:` block as well.
on:
push:
branches:
- main
paths:
- '.github/**'
pull_request:
types: [opened, synchronize, reopened]
paths:
- '.github/**'
workflow_dispatch:

permissions:
contents: read

jobs:
actionlint:
name: Lint Workflows
runs-on: ubuntu-latest
timeout-minutes: 10
concurrency:
group: check-${{ github.workflow }}-${{ github.ref }}-actionlint
cancel-in-progress: true
permissions:
contents: read
steps:
- uses: actions/checkout@v6

# The Docker image bundles shellcheck and pyflakes, so this lints every
# `run:` block too. A native actionlint binary without shellcheck on
# PATH silently skips the shell checks and still exits 0 -- worth knowing
# before trying to reproduce a finding locally.
- name: Lint workflow files
uses: docker://rhysd/actionlint:1.7.7
with:
args: -color
3 changes: 2 additions & 1 deletion .gitkeep
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
# .gitkeep file auto-generated at 2026-08-20T04:56:54.559Z for PR creation at branch issue-60-1431435e7081 for issue https://github.com/link-foundation/python-ai-driven-development-pipeline-template/issues/60
# .gitkeep file auto-generated at 2026-08-20T04:56:54.559Z for PR creation at branch issue-60-1431435e7081 for issue https://github.com/link-foundation/python-ai-driven-development-pipeline-template/issues/60
# Updated: 2026-08-28T10:49:28.004Z
17 changes: 17 additions & 0 deletions changelog.d/20260828_issue_62_workflow_lint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
### Added

- Added a `Workflows` job that runs `actionlint` (via the Docker image, so
`shellcheck` lints every `run:` block too) on any change under `.github/`.
No workflow in the template validated its own workflow files before, which
is why the defect below survived.

### Fixed

- Removed the unsupported `queue: max` key from the two write-capable
`concurrency:` blocks in `release.yml`. GitHub Actions accepts only `group`
and `cancel-in-progress`, so the key never had any effect and documented a
queuing guarantee the workflow did not have. Behaviour is unchanged; a
regression test now rejects unknown concurrency keys in every workflow.
- Quoted the `>> "$GITHUB_OUTPUT"` redirections and grouped the Docker publish
config writes in `release.yml`, clearing every remaining `shellcheck`
finding actionlint reports.
96 changes: 94 additions & 2 deletions tests/test_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,36 @@ def workflow_run_blocks(workflow: str) -> list[str]:
return blocks


SUPPORTED_CONCURRENCY_KEYS = frozenset({"group", "cancel-in-progress"})


def concurrency_keys(workflow: str) -> list[str]:
"""Return every key declared inside a ``concurrency:`` mapping."""
lines = workflow.splitlines()
keys: list[str] = []

for index, line in enumerate(lines):
header = re.match(r"^(\s*)concurrency:\s*$", line)
if not header:
continue

block_indentation = len(header.group(1))
key_indentation = block_indentation + 2
for next_line in lines[index + 1 :]:
if not next_line.strip() or next_line.lstrip().startswith("#"):
continue
indentation = len(next_line) - len(next_line.lstrip())
if indentation <= block_indentation:
break
if indentation != key_indentation:
continue
key = re.match(r"([A-Za-z0-9_-]+):", next_line.lstrip())
if key:
keys.append(key.group(1))

return keys


def assert_action_pin_count(
workflow: str, action: str, version: str, count: int
) -> None:
Expand Down Expand Up @@ -206,7 +236,7 @@ def test_changelog_check_safely_requires_a_fragment() -> None:


def test_release_workflow_separates_check_and_write_concurrency() -> None:
"""Checks supersede off main while release writes share a FIFO queue."""
"""Checks supersede off main while release writes share one group."""
workflow = read_workflow("release.yml")
workflow_header = workflow.split("\njobs:\n", maxsplit=1)[0]

Expand All @@ -232,7 +262,6 @@ def test_release_workflow_separates_check_and_write_concurrency() -> None:
" concurrency:",
" group: ${{ github.workflow }}-main-write",
" cancel-in-progress: false",
" queue: max",
)
)
for job_name in ("auto-release", "manual-release"):
Expand Down Expand Up @@ -760,3 +789,66 @@ def test_long_release_steps_own_an_execution_deadline() -> None:
f"release.yml: step `{step_name}` of job `{job_name}` has no "
"step-level timeout, so an overrun cancels the job (issue #60)"
)


def test_concurrency_key_parser_reports_unsupported_keys() -> None:
"""The scanner below has to see a key GitHub would silently ignore."""
invalid = "\n".join(
(
"jobs:",
" publish:",
" concurrency:",
" group: main-write",
" # Not a real key.",
" cancel-in-progress: false",
" queue: max",
" steps: []",
)
)

assert concurrency_keys(invalid) == [
"group",
"cancel-in-progress",
"queue",
]


def test_workflow_concurrency_blocks_use_only_supported_keys() -> None:
"""GitHub ignores unknown concurrency keys instead of rejecting them.

Regression test for issue #62: ``queue: max`` documented a queuing
guarantee the workflow never had. The syntax accepts only ``group`` and
``cancel-in-progress``; with ``cancel-in-progress: false`` GitHub keeps the
running job and holds a single pending run per group.
https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#concurrency
"""
checked = 0

for path in sorted(WORKFLOWS.glob("*.y*ml")):
keys = concurrency_keys(path.read_text(encoding="utf-8"))
unsupported = sorted(set(keys) - SUPPORTED_CONCURRENCY_KEYS)
assert not unsupported, (
f"{path.name}: concurrency blocks declare {unsupported}, which "
"GitHub Actions ignores silently. Only "
f"{sorted(SUPPORTED_CONCURRENCY_KEYS)} exist."
)
checked += len(keys)

assert checked >= 2, f"expected concurrency blocks to be scanned, saw {checked}"


def test_workflow_lint_job_validates_every_workflow() -> None:
"""actionlint has to run in CI, or this class of defect goes unnoticed.

It is what reports both halves of issue #62: the unsupported concurrency
key, and (only when shellcheck is on PATH) the shell findings inside every
``run:`` block.
"""
workflow = read_workflow("workflows.yml")
job = workflow_job_block(workflow, "actionlint")

assert "paths:" in workflow and "'.github/**'" in workflow
assert "timeout-minutes:" in job
# The Docker image bundles shellcheck and pyflakes; a bare binary without
# shellcheck on PATH skips the shell checks and still exits 0.
assert "docker://rhysd/actionlint:" in job
Loading