Skip to content

Close the reusable workflows' release, audit and check gaps - #62

Merged
ahegyes merged 23 commits into
trunkfrom
fix/reusable-workflows
Nov 6, 2026
Merged

ahegyes merged 23 commits into
trunkfrom
fix/reusable-workflows

Conversation

@ahegyes

@ahegyes ahegyes commented Nov 6, 2026 •

Copy link
Copy Markdown
Owner

Summary

The release, supply-chain, end-to-end, block.json, CodeQL and workflow-check workflows let broken releases, unlocked tools and misread reports through; this fixes each case, adds prerelease tags and build provenance attestations, and simplifies the release and audit shell.

Type of change

  • PHP source (Composer scripts, php-scoper config)
  • Reusable workflow (.github/workflows/reusable-*.yml)
  • PHPCS / PHPStan ruleset (php/quality-assurance/)
  • Node config (eslint, stylelint, playwright, tsconfig)
  • Documentation
  • Other

Checklist

  • Tests added or updated (PHPUnit suites under tests/Unit/)
  • composer test passes locally
  • If this changes a reusable workflow's inputs/outputs, docs/workflows.md is updated to reflect the new contract
  • If this changes a ruleset, downstream consumer behavior is considered (this repo ships to dev-trunk consumers immediately)

Breaking changes for downstream consumers

Move each consumer's pin in one window, since a caller missing a permission fails at startup without a failed check on the pull request that moved the pin.

  • Release permissions: callers of reusable-release.yml grant id-token: write and attestations: write beside actions: read and contents: write. In a public repository the release attests its zip's build provenance; a private or internal one releases without an attestation.
  • Defaults: npm-audit-flags defaults to --omit=dev --audit-level=high, so callers passing that value can drop it, and a caller that audits development dependencies passes --audit-level=high. reusable-php-syntax-check.yml checks PHP 8.5 and 8.6 (a nightly build) by default. reusable-playwright-e2e.yml exports WP_ENV_PHP_VERSION only when the wp-env config and its override pin no phpVersion, as the PHPUnit workflow does.
  • Release build: with a CHANGELOG.md, every run, dry runs included, needs a non-blank section for the header's version, which becomes the release notes. WC requires at least must cover each shipped package's extra.requires-wc, and the floors read Composer's configured vendor directory. The archive must ship every PHP file under vendor-prefixed/ and must not ship AGENTS.md, CLAUDE.md, changelog/, PHPStan configs, PHPUnit caches or any .key file; the baseline .distignore excludes them all, so copy it again.
  • Artifact test: the wp-env redirect merges into an existing .override.json rather than replacing it, clears core in each environment, and requests the site on the development port the config and its override set.
  • Composer: this package conflicts with humbug/php-scoper outside 0.18.

Additive: v1.2.3-beta.1-style tags publish GitHub prereleases and skip wp.org; reusable-workflow-checks.yml takes an optional extra-workflows list for actionlint and pins actionlint; CodeQL skips analysis where GitHub Advanced Security is off instead of failing; the block.json check validates with a locked ajv-cli from this repository's commit; Composer cooldown findings follow fail-on-findings.

The locks trailed the latest releases within range. The root Composer
lock needs nothing; the release tools' lock gains Composer 2.11's
published-time metadata. npm moves eslint (under wp-scripts) to 10.14.0
and minimizer-webpack-plugin to 5.14.0; TypeScript stays on 6, which
typescript-eslint caps, and the cooldown's downgrade of
@babel/helper-create-regexp-features-plugin to 7.29.7 is kept back at
7.29.10.

Workflow Checks' zizmor pin, which Dependabot cannot bump, trailed as
well; it moves to 1.31.1, the newest release the pinned zizmor-action
supports.

Assisted-by: Claude Code:claude-opus-5-5
Rows for the behavior the reusable workflows lack, each failing on
trunk:

- release: prerelease tags and their versions, notes and the wp.org
  refusal; the WooCommerce floor from extra.requires-wc; Composer's
  configured vendor directory; the forbidden development files and
  private keys under vendor/; every scoped PHP file in the archive; the
  changelog section checked in the build and handed to the release; the
  wp-env override merged, with core cleared per environment, and the
  site requested on the effective port;
- the end-to-end workflow resolving the site's PHP with the PHPUnit
  workflow's step, and naming a missing @playwright/test;
- Composer's cooldown findings read as findings;
- Workflow Checks' extra-workflows list for actionlint;
- the GHAS probe in reusable CodeQL, which runs analyze on an unclear
  answer;
- the block.json check validating with the ajv-cli node/block-json/
  locks.

npm audit rows pin today's advisory handling before its rewrite.
run_step() reads multi-line `name<<delimiter` values, which the release
notes output uses.

Assisted-by: Claude Code:claude-opus-5-5
The bootstrap updater has a prerelease channel that scans a plugin's
newest releases for dev, alpha, beta and rc versions, but the release
workflow refused every tag except vMAJOR.MINOR.PATCH, so nothing could
produce one.

A tag may add -dev, -alpha, -beta or -rc and an optional .N. Its version
must still equal the main file's Version header, but not readme.txt's
Stable tag, which keeps naming the last stable release. The release is
created with --prerelease, which GitHub's latest release skips, and the
wp.org deploy never runs for it.

Assisted-by: Claude Code:claude-opus-5-5
The release job alone looked up the version's CHANGELOG.md section, so a
dry run passed what the tagged run then failed, and a section holding
only blank lines published an empty description.

The build extracts the section beside the version check, fails when it
is missing or blank, and hands it to the release job as the multi-line
release-notes output, so the tagged run publishes exactly the notes the
dry run checked. The release job then reads nothing from the tree and
drops its checkout.

Assisted-by: Claude Code:claude-opus-5-5
The release gate checked Requires PHP and Requires at least against the
shipped packages but no WooCommerce floor, so a plugin could declare a
WC requires at least below what a bundled package needs. It also read
production packages only from vendor/composer/installed.json, so a
project with a custom Composer vendor-dir skipped the PHP-floor check
for every production package.

A shipped package's extra.requires-wc now sets a WC requires at least
floor, as extra.requires-wp sets Requires at least, and the installed
packages come from the vendor directory composer config reports.

Assisted-by: Claude Code:claude-opus-5-5
The completeness check passed a scoped package as long as any one of its
files reached the zip, so a .distignore pattern that dropped its code
while its composer.json or README shipped went unnoticed until the
plugin fataled. Every PHP file under vendor-prefixed/ must now ship,
scoper-autoload.php included, while a package's metadata may stay out;
the template, Linked Orders, Internal Comments and Locked Payment
Methods archives pass it.

The forbidden-entry check let profile PHPStan configs, PHPUnit caches,
AGENTS.md, CLAUDE.md and changelog/ fragments through, and exempted
.key files under vendor/ with the certificates. Those files now fail the
build, and only .pem certificates under vendor/ and vendor-prefixed/
stay exempt.

Assisted-by: Claude Code:claude-opus-5-5
The artifact test wrote the config's override file from scratch, so any
override the project kept, such as its own constants or a co-mounted
plugin, was dropped, and it cleared core only at the root, so a core
pinned for the development or tests environment still ran the E2E
against that pin. The site check also read the port from the config
alone.

The redirect and core: null now merge into the existing override at the
root and in both environments, and the site is requested on the
development port the config and its override set. docs/workflows.md
also says that a newer tag cancels an older one's pending release, which
is re-run from its tag's run.

Assisted-by: Claude Code:claude-opus-5-5
A release attached its zip to the GitHub release with nothing that ties
it to the workflow run that built it, so a downloaded copy could not be
checked against its build.

The release job attests the verified zip with
actions/attest-build-provenance before it creates the release, which
gh attestation verify checks. GitHub offers attestations in private and
internal repositories only on Enterprise Cloud, so the step runs only
in a public repository and a private one still releases. Callers grant
id-token: write and attestations: write as well.

Assisted-by: Claude Code:claude-opus-5-5
The Playwright workflow exported php-version as WP_ENV_PHP_VERSION for
the whole job, which wp-env applies over every phpVersion, so a suite
aimed at a site pinned to an older PHP ran on php-version instead,
while the PHPUnit workflow keeps the pin.

The end-to-end workflow drops the job-level variable and runs the
PHPUnit workflow's "Resolve the site's PHP" step verbatim, which a test
holds identical. A project without @playwright/test now fails with a
named error instead of an empty version, and the browser install runs
npx with --no-install, as the release does.

Assisted-by: Claude Code:claude-opus-5-5
Composer 2.11 fails an audit on a package still in policy.cooldown's
window when that policy says fail, and reports it under a cooldown key
the classifier never read, so such a run was read as a broken report
and failed even with fail-on-findings off. Cooldown entries now count
as findings beside advisories and abandoned packages, and
docs/workflows.md says what the classifier does: any filter-list entry
fails the audit.

npm-audit-flags defaulted to the full dependency graph, which every
caller overrode with --omit=dev --audit-level=high, the gate this
project runs. That is now the default, and configs' own caller drops
its override.

Assisted-by: Claude Code:claude-opus-5-5
A monorepo whose packages ship their own workflows under
plugins/*/.github/workflows had no way to lint them: actionlint reads
only the root's .github/workflows. extra-workflows, an optional JSON
array of globs, now joins those files to actionlint's list. zizmor
needs nothing, because its default input already audits every workflow
in the repository and skips gitignored trees.

actionlint ran whatever release the action fetched, beside a pinned
zizmor; it is now pinned to 1.7.12, and CONTRIBUTING names both pins as
manual bumps, since Dependabot sees neither. configs' own Workflow
Checks also runs when zizmor.yml or dependabot.yml changes.

Assisted-by: Claude Code:claude-opus-5-5
Every consumer's release installs WP-CLI and its commands from
php/release/composer.lock, but the supply-chain audit read only the
root's locks, so an advisory in that tree never failed configs' CI.
Quality now audits it as well, with the npm audit off, since it holds
no npm manifest.

Assisted-by: Claude Code:claude-opus-5-5
The block.json check ran `npx --yes ajv-cli@5.0.0`, which fixes only the
top-level version, so every consumer's CI resolved ajv-cli's dependency
tree fresh from the registry, unlocked, uncooled and unseen by
Dependabot.

node/block-json/ now holds the manifest and lock of that ajv-cli, as
php/release/ does for the release's WP-CLI commands. The job checks it
out from the workflow's own commit through job.workflow_repository and
job.workflow_sha, installs it with npm ci and calls its ajv. Dependabot
keeps the lock current, Quality audits it, and the npm package leaves
the directory out. ajv-cli's last release requires fast-json-patch 2,
whose prototype-pollution advisory is high, so an override holds it at
3.1.1, under which ajv validates the fixture and rejects an invalid
block as before. actionlint 1.7.12 does not know the two job.workflow_*
properties yet, so .github/actionlint.yaml ignores those two messages
for that file.

Assisted-by: Claude Code:claude-opus-5-5
On a private repository without GitHub Advanced Security, CodeQL's
analyze step fails on its SARIF upload, so reusable CodeQL failed every
run there, while Workflow Checks already probes for it.

CodeQL now runs a8csp-configs' probe first and skips analyze when the
code-scanning API answers that Advanced Security is off. An answer that
stays unclear after three tries counts as available, unlike Workflow
Checks' probe, because a skipped analyze would pass as a green check
while zizmor still runs and fails on findings there.

Assisted-by: Claude Code:claude-opus-5-5
The syntax check defaulted to PHP 8.5 alone, so a construct the next PHP
deprecates or removes surfaced only when a project raised its PHP. The
default now also runs 8.6, which setup-php installs as a nightly build,
as a8csp-configs does.

Assisted-by: Claude Code:claude-opus-5-5
The scoping pipeline relies on php-scoper 0.18's behavior but declared
the range only as its own development requirement, which a consumer's
install never sees, so a consumer could scope with another minor. A
conflict now holds php-scoper to 0.18, and the README says so.

The pipeline also reads jetbrains/phpstorm-stubs directly, which
php-scoper 0.18 installs, without naming it; suggest names it beside
php-scoper. The composer/* plugin allowance, which matched no installed
plugin, goes.

Assisted-by: Claude Code:claude-opus-5-5
The release workflow said several things twice or at length: two
helper functions behind one project-path check, eight case arms
sharing one body, a GITHUB_ENV copy of a step output, a second
Node-version check on the commit the build had checked, and Composer
installs with differing flags. Each now says it once, and every check
keeps its outcome:

- the release input checks on project-path are one condition with one
  message, and a row pins its character-pattern branch;
- the eight forbidden-entry arms that shared one body are one arm, the
  certificate and secret arms keeping their order;
- the POT check reads the scoping step's output in its condition, so
  the job no longer copies that fact into GITHUB_ENV;
- the wp.org job takes its version from the build's output and keeps
  its own tag check, with a line saying why;
- the test job drops the Node-version check the build already ran on
  the same commit;
- the release's Composer installs share one flag set.

Assisted-by: Claude Code:claude-opus-5-5
The release's floor check and the Composer audit's classifier were
written in a compact style the repository's PHP does not use: [] arrays,
no spaces inside parentheses, and array_filter and array_map closures
where a loop reads plainer. Both now use array(), WPCS spacing, Yoda
comparisons against preg_match() and a foreach for the floor messages,
and the classifier moves from php -r into a heredoc, so its strings take
single quotes. Messages and exit statuses are unchanged; extracted, both
scripts pass WPCS but for the short ternary configs' base allows and
the WordPress-globals sniff, which a CI script never meets.

Assisted-by: Claude Code:claude-opus-5-5
Both audit steps toggled errexit around the audit to capture its status,
and the npm step classified its report with a Node heredoc chained to an
error block. Each step now captures the status with `|| status=$?`, and
the npm report is classified by one jq expression with the same
outcomes: findings in a readable report are softened, while an error
report or an unreadable one fails. Quality's PHPCS smoke drops a
`set -e -o pipefail` that its `shell: bash` already applies.

Assisted-by: Claude Code:claude-opus-5-5
The release's archive check gained AGENTS.md, CLAUDE.md, changelog/,
every phpstan*.neon* and .phpstan*.neon* file, PHPUnit's result cache
and any .key file under vendor/, but the baseline consumers copy
excluded none of them, so a project built from the baseline failed its
release on a file the baseline let through, such as PHPStan's own
phpstan.neon.dist. The baseline now excludes each of them, and *.key
at any depth, as the release requires; .pem stays root-anchored, so
certificate bundles under vendor/ still ship. A wp dist-archive build
of a probe plugin from the baseline passes the release's check.

Assisted-by: Claude Code:claude-opus-5-5
The site check's port lookup lost jq's -r flag when it began merging
the override, so a port written as a string would reach curl in
quotes. The wp.org job's comment also said it trusts nothing the
earlier jobs found, though it takes its version from the build; it now
says what its ref check guards.

Assisted-by: Claude Code:claude-opus-5-5
A port test ran a statement straight after a closing brace, and the
CodeQL test explained its curl fake in a comment its sibling fakes do
without.

Assisted-by: Claude Code:claude-opus-5-5
A prerelease leaves readme.txt's Stable tag at the last stable release,
but with wp-org on the build runs Plugin Check's readme check, which
reports a Stable tag that differs from the header's Version as the
stable_tag_mismatch error, so every prerelease of a wp.org plugin
failed its build. The build now passes that code to Plugin Check's
ignore list when the header's version is a prerelease.

Assisted-by: Claude Code:claude-opus-5-5
@ahegyes ahegyes changed the title Fix/reusable workflows Close the reusable workflows' release, audit and check gaps Nov 6, 2026
@ahegyes
ahegyes merged commit ac90ca8 into trunk Nov 6, 2026
37 checks passed
@ahegyes
ahegyes deleted the fix/reusable-workflows branch November 6, 2026 19:03
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