diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4a12662..210d429 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: @@ -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 @@ -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" @@ -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 @@ -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. @@ -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 @@ -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}" \ diff --git a/.github/workflows/workflows.yml b/.github/workflows/workflows.yml new file mode 100644 index 0000000..56819a4 --- /dev/null +++ b/.github/workflows/workflows.yml @@ -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 diff --git a/.gitkeep b/.gitkeep index bb655d9..2a80916 100644 --- a/.gitkeep +++ b/.gitkeep @@ -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 \ No newline at end of file +# .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 \ No newline at end of file diff --git a/changelog.d/20260828_issue_62_workflow_lint.md b/changelog.d/20260828_issue_62_workflow_lint.md new file mode 100644 index 0000000..5c2c3db --- /dev/null +++ b/changelog.d/20260828_issue_62_workflow_lint.md @@ -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. diff --git a/tests/test_workflows.py b/tests/test_workflows.py index b0935f1..763050f 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -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: @@ -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] @@ -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"): @@ -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