Skip to content

Lint GitHub Actions workflows on pull requests, and test the parsed Publish job - #597

Merged
MaxGhenis merged 5 commits into
masterfrom
ci-actionlint
Oct 7, 2026
Merged

MaxGhenis merged 5 commits into
masterfrom
ci-actionlint

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Lint workflows on every PR. A new "Lint workflows" job in .github/workflows/pr.yaml runs actionlint 1.7.12, the latest release (2026-03-30). It uses the official rhysd/actionlint image pinned by digest sha256:b1934ee5…; on Docker Hub, both the 1.7.12 and latest tags resolve to that digest. The image bundles shellcheck and pyflakes, so actionlint also checks run: scripts. The job runs with contents: read, and its checkout doesn't keep the token (persist-credentials: false).
  • Fix the two actionlint warnings on master:
    • SC2046 in push.yaml, versioning job, "Build changelog" step: the $(python -c …) passed to towncrier build --version is now quoted. The Makefile changelog target had the same pattern and is quoted too.
    • Deprecated input in Publish: skip_existing is now skip-existing. In pypa/gh-action-pypi-publish@release/v1's action.yml, skip-existing is the canonical input; skip_existing is a deprecated alias passed through as inputs.skip-existing || inputs.skip_existing, so behavior is unchanged.
  • The release-ordering test parses YAML. test_publish_workflow_tags_only_after_pypi_succeeds used to slice the file text from " Publish:\n" to the end. It now asserts on yaml.safe_load(PUSH_WORKFLOW.read_text())["jobs"]["Publish"].
  • Passes today, not only after Deploy the documentation site from its own release job #594. .github/actionlint.yaml ignores two error messages, in push.yaml only. Both come from the Test job's "Deploy documentation" step, which Deploy the documentation site from its own release job #594 fixes.
  • The README's development section gives the local actionlint command, pinned to the same 1.7.12. Run it from the repo root: actionlint matches the ignores against paths relative to the working directory.

Background: #594 fixes a release step that was skipped on every release since 2024-09-26. It was guarded by if: matrix.os == 'ubuntu-latest' in a job with no matrix, and actionlint reports exactly that (property "os" is not defined in object type {}).

Why the two ignores

Without them, Lint workflows would fail on every PR until #594 merges, and #594 waits on a go/no-go about resuming the public docs site. The two ignored errors are the matrix.os condition and the GITHUB_TOKEN input, which JamesIves/github-pages-deploy-action@v4 does not define. actionlint matches ignores by message only, so these are as narrow as it allows: one file and two exact messages.

The cost: until the entries are deleted, a new matrix.<key> reference in a matrix-less job in push.yaml would also be ignored. I checked this by adding one to Publish; actionlint stayed clean. pr.yaml and every other check are unaffected. #594's own tests/core/test_docs_deployment.py asserts that no job in either workflow references an undefined matrix key, so once #594 lands that bug is covered even if the ignore lingers. The config comment says to delete both entries in whichever PR fixes or removes the step.

The test rewrite

The parsed test asserts, on Publish only:

Property Why
permissions.contents == "write" (or write-all) The tag script pushes a tag; another job's permissions don't apply.
checkout fetch-depth is 0 (compared as a string, since action inputs are strings) .github/publish-git-tag.sh looks for an existing tag locally, and a checkout with fetch-depth: 0 fetches every tag.
tag step after the PyPI step Steps run in order, and a failure skips the rest.
no if: on either step; no continue-on-error on the PyPI step Any of these lets the tag step run without a successful upload. A skipped step doesn't fail the job, so an if: that skips the upload (say github.ref == 'refs/heads/main' on this master repo) still lets the tag through.
no continue-on-error on the tag step; its run is exactly bash .github/publish-git-tag.sh A failed tag push must fail the job. The exact match rejects || true, || exit 0, set +e and the like.

Exactly one checkout, one PyPI and one tag step must exist, each with a message naming what's missing. The if: and continue-on-error checks go beyond the brief: they close the remaining ways the tag could run without a successful upload.

Eight parametrized mutation cases each break one property and assert that the check fails with that property's message, so no assertion is vacuous. Two more cases check that equivalent settings (permissions: write-all, fetch-depth: "0") pass. The first case is the false pass from the review of #594: Publish's permissions deleted and a later Docs job with contents: write.

That false pass, on the real file (master's push.yaml with Publish's permissions block removed and a Docs job appended after Publish):

old text-slice test:  1 passed
new parsed test:      AssertionError: Publish lacks contents: write

Merge order

Checks run

  • actionlint 1.7.12 (uvx --from actionlint-py==1.7.12.25 actionlint, shellcheck 0.11.0):
  • Mutation check of the lint job: restoring each fix brings back its exact master finding (SC2046:warning:3:33; the deprecated-input error). A matrix.os condition in a pr.yaml job and a needs: typo each fail.
  • The quoted towncrier commands, run for real in throwaway worktrees with a fragment (make changelog, and the push.yaml step script extracted from the parsed YAML): both bump to 3.32.21. The CHANGELOG.md and pyproject.toml they produce are byte-identical to master's unquoted command.
  • pytest tests/core/test_release_tagging.py: 15 passed (Python 3.13). The edge cases the review raised: write-all and fetch-depth: "0" pass; read-all, fetch-depth: false, fetch-depth: 1, || exit 0 and set +e each fail on their own message.
  • uvx ruff format --check .: 339 files already formatted. ruff check on the test file: clean.
  • CI on this head: queued at hand-off. Every PolicyEngine repo is waiting for runners: policyengine-us alone had 44 runs queued, with 2 in progress org-wide.

Not run locally: the Docker image (Docker isn't running on the machine that made this change). The config should still apply in CI, for three reasons:

  • Config loading: actionlint v1.7.12, run with no file arguments, finds the project root by walking up to a directory with both .github/workflows and .git (project.go), then loads .github/actionlint.yaml from it (config.go).
  • Glob matching: it matches the paths: glob against each file's path relative to the working directory (linter.go).
  • Working directory: this job's earlier run on cf8fb6a1 started the container with --workdir /github/workspace and reported .github/workflows/push.yaml:79:13, so the glob sees exactly .github/workflows/push.yaml.

The first green Lint workflows run on this PR will confirm it.

Review

An independent Opus review on Subfleet (20261006-172501-core597-review) of d67d103f returned APPROVE WITH NITS. That reviewer had no shell, so it worked from source. The commit after it, cbb88b57, fixes all its nits:

  • the "fetch-depth 0 fetches tags only" wording, since checkout's fetch-tags also fetches tags;
  • fetch-depth: false passing as 0;
  • write-all raising AttributeError;
  • matching the tag script exactly instead of a || true regex;
  • the README pin.

Its last finding, deleting the stale ignores once #594 lands, is a merge step. It's covered above and in a comment on #594.

A second reviewer, an in-session Opus agent that ran every command above, reviewed cbb88b57 and returned APPROVE WITH NITS. It reproduced the false pass and traced the paths: matching through actionlint's source and the cf8fb6a1 CI log. b7c4977f fixes its nits:

  • a PyPI-step if: let the tag through;
  • write-all and "0" had no passing test;
  • the README didn't say to run from the repo root;
  • the lint job could drop its write token.

The same reviewer approved b7c4977f, and then approved 9a30bd55, which changes one word of a comment.

This PR combines two sessions started from the same follow-up. The first opened it with the lint job and the two fixes (cf8fb6a1). The second added the test rewrite, the ignore config, the hardening and the README line (d67d103f onward).

Documentation review

  • Documentation changes: the README's development section gains the local actionlint command. No other durable docs describe the PR checks.
  • Impact: low. CI and tests only; no package code changes.
  • Confidence: high. Every claim above comes from a command run on this head or from the action's action.yml.

axiom: n/a: CI workflow and test change, no policy change

🤖 Generated with Claude Code

MaxGhenis and others added 2 commits October 6, 2026 16:31
Add a "Lint workflows" job to the pull request workflow that runs
actionlint 1.7.12 from the official rhysd/actionlint image, pinned by
digest. The image bundles shellcheck and pyflakes, so `run:` scripts are
checked too.

Fix the two warnings it reports on master that #594 does not touch:
quote the version substitution passed to `towncrier build --version`
(SC2046) in push.yaml and in the Makefile `changelog` target, and rename
the deprecated `skip_existing` input of pypa/gh-action-pypi-publish to
`skip-existing`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
test_publish_workflow_tags_only_after_pypi_succeeds sliced push.yaml's
text from "  Publish:" to the end of the file. With a later job holding
contents: write and Publish's own permissions deleted, the slice read
the later job's block and the test passed. It now asserts on
yaml.safe_load(...)["jobs"]["Publish"]: contents: write, a fetch-depth 0
checkout, the tag step after the PyPI step with no condition, no
continue-on-error on either step, and no `|| true` on the tag script.
Mutation cases check that each assertion rejects the mistake it names.

Add .github/actionlint.yaml so Lint workflows passes before #594 lands:
it ignores, in push.yaml only, the two errors in the Test job's docs
deploy step that #594 fixes. Delete both entries in whichever PR fixes
or removes that step.

Document running actionlint locally in the README.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis MaxGhenis changed the title Lint GitHub Actions workflows on pull requests Lint GitHub Actions workflows on pull requests, and test the parsed Publish job Oct 6, 2026
- Accept `permissions: write-all`, which also grants contents: write,
  and fail with the named message (not AttributeError) on any other
  string.
- Compare fetch-depth as a string: action inputs are strings, so 0 and
  "0" are the same input, and `false` no longer passes as 0.
- Require the tag step to run exactly `bash .github/publish-git-tag.sh`,
  which also rejects `|| exit 0`, `set +e` and the like, not only
  `|| true`.
- Say that fetch-depth 0 fetches every tag, not that only it does:
  checkout's fetch-tags input also fetches tags.
- Pin the README's local actionlint command to the version CI runs, and
  mention pyflakes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis and others added 2 commits October 6, 2026 18:14
From the delta review of cbb88b5:

- A condition on the PyPI step (for example `if: github.ref ==
  'refs/heads/main'` on a master-branch repo) skips the upload without
  failing the job, so the tag step still ran. The check now rejects it,
  with its own mutation case.
- Test that the equivalent settings the check accepts, `write-all` and
  `fetch-depth: "0"`, pass.
- Lint workflows runs with `contents: read`, and its checkout does not
  persist the token into the workspace the container mounts.
- The README notes that actionlint must run from the repo root: the
  path-scoped ignores match paths relative to the working directory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An `if: success()` on either step changes nothing; the check still
rejects any `if:`, since most conditions would skip a step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Hand-off to the US + core hub.

  • Head: 9a30bd555b9766864f6cc0a0b834cac9605b242e, base master 757147c7. The PR is MERGEABLE and not a draft.
  • Review:
    • Subfleet Opus 20261006-172501-core597-review returned APPROVE WITH NITS on d67d103f. Its nits are fixed in cbb88b57.
    • An in-session Opus agent, which ran every command, returned APPROVE WITH NITS on cbb88b57. Its nits are fixed in b7c4977f.
    • The same agent returned APPROVE on b7c4977f and on 9a30bd55, a one-word comment change.
  • Passed locally at this head:
    • actionlint 1.7.12: exit 0. With an empty config, exactly the 2 Deploy the documentation site from its own release job #594 errors.
    • pytest tests/core/test_release_tagging.py: 15 passed.
    • ruff format --check .: clean.
    • Old vs new test on the false-pass mutation: the old test passes, the new one fails.
  • Pending: all CI. It is queued behind the org-wide runner backlog, so nothing has failed. Lint workflows is the first CI run with .github/actionlint.yaml. Source reading and the cf8fb6a1 job log both say the ignores apply in the container (cwd /github/workspace). If it fails anyway, the cause is that config, not the workflows.
  • Merge order, no dependency:
  • Holds and decisions: none for this PR. Deploy the documentation site from its own release job #594 itself waits on d1028.
  • Follow-ups, already filed as chips: remove the dead .github/*.sh release scripts and shellcheck the live one; declare PyYAML as a direct dependency.

Review records, the #591 resolution and the drafts are in ~/reviews/policyengine-core-597/ on Max's machine.

@MaxGhenis
MaxGhenis merged commit 7e60861 into master Oct 7, 2026
19 checks passed
@MaxGhenis

Copy link
Copy Markdown
Contributor Author

US + core hub merge audit, core#597 at 9a30bd555b9766864f6cc0a0b834cac9605b242e (squash)

@MaxGhenis
MaxGhenis deleted the ci-actionlint branch October 7, 2026 13:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant