diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e8c2074..4d8cadb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,11 +3,13 @@ name: CI # PR gate: PHPCS, PHPUnit, and Plugin Check run on ubuntu-24.04 via wp-env. # E2E tests run only on release/* branches — see pre-release.yml. +# PR gate only. main is deliberately absent: on this repository main advances +# solely by merging a release PR, and release-artifact.yml re-runs these same +# checks against the built artifact before it publishes anything. Running them +# again on the push would be a third execution of a tree already tested twice. on: pull_request: branches: [main, 'feature/**', 'fix/**'] - push: - branches: [main] permissions: contents: read diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml index 4c1583f..0522add 100644 --- a/.github/workflows/pre-release.yml +++ b/.github/workflows/pre-release.yml @@ -16,16 +16,18 @@ name: Pre-release # Minimum WP 6.9 × PHP 7.4 (matches "Requires at least" in readme.txt) # Nightly WP × PHP 8.4 +# Manual only. It used to fire on release/* PRs as well, which meant the release +# PR ran this full sweep AND ci.yml's subset, and then release-artifact.yml built +# again on the merge — three executions of the same checks and two builds per +# release. release-artifact.yml now gates the artifact it publishes, so dispatch +# this when you want the full compatibility matrix run on top of that. on: workflow_dispatch: - pull_request: - branches: [main] jobs: lint: name: PHPCS (PHP 8.3 × WP 7.1) runs-on: ubuntu-24.04 - if: startsWith(github.head_ref, 'release/') || github.event_name == 'workflow_dispatch' steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -38,7 +40,6 @@ jobs: lint-js: name: JS/SCSS lint runs-on: ubuntu-24.04 - if: startsWith(github.head_ref, 'release/') || github.event_name == 'workflow_dispatch' steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -52,7 +53,6 @@ jobs: unit: name: PHPUnit (PHP 8.3 × WP 7.1) runs-on: ubuntu-24.04 - if: startsWith(github.head_ref, 'release/') || github.event_name == 'workflow_dispatch' steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -65,7 +65,6 @@ jobs: build: name: Build test zip runs-on: ubuntu-24.04 - if: startsWith(github.head_ref, 'release/') || github.event_name == 'workflow_dispatch' steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 diff --git a/.github/workflows/publish-plugin.yml b/.github/workflows/publish-plugin.yml index 0641ac7..881ffb3 100644 --- a/.github/workflows/publish-plugin.yml +++ b/.github/workflows/publish-plugin.yml @@ -1,8 +1,18 @@ name: Publish Plugin -# Ships a tagged release to the plugin's WordPress.org SVN repository. Dispatch it -# manually against a v tag — the tags created by release-artifact.yml on -# merge to main. Nothing here runs automatically. +# Ships an already-built release to the plugin's WordPress.org SVN repository. +# Dispatch it manually with the `tag` of a release created by release-artifact.yml. +# Nothing here runs automatically. +# +# It builds nothing. The payload is the release asset release-artifact.yml already +# built, tested (PHPCS, PHPUnit, Plugin Check) and attached to that tag — so the +# bytes reaching WordPress.org are the bytes that were tested, rather than a second +# build that merely ought to match. This workflow's only question about the payload +# is whether it exists. +# +# Dispatch from any ref: the `tag` input, not the dispatch ref, decides what is +# published. The repository is checked out solely for the staging script, so you get +# current tooling applied to a tagged payload. # # This workflow is authored in the private repo at .github/public/workflows/ and # mirrored here by release-mirror.yml. It is not dispatchable from the private @@ -15,9 +25,9 @@ name: Publish Plugin on: workflow_dispatch: inputs: - version: - description: 'Version to publish — defaults to package.json at the tag, and must match it. x.y.z, or a pre-release x.y.z-beta1 / x.y.z-rc1' - required: false + tag: + description: 'Release tag to publish, e.g. v0.4.1. Its GitHub Release must already carry the built artifact.' + required: true type: string dry_run: description: 'Report what would be published and make no SVN change' @@ -57,61 +67,30 @@ jobs: published: ${{ steps.verify.outputs.published }} steps: - # Publishing is only ever done from a version tag, never a branch. A branch - # moves: dispatching from release/* could publish commits pushed after CI went - # green. A tag cannot, so what is published is exactly what was tested, and a - # re-run republishes byte-identical content. Tags are created by - # release-artifact.yml on merge to main. - # - # Fail before doing any work, rather than after building an artifact nobody - # will use. - - name: Guard - dispatch ref must be a version tag - run: | - if [[ "${GITHUB_REF}" != refs/tags/v* ]]; then - echo "::error::Refusing to publish from '${GITHUB_REF}'. Dispatch this workflow with a v tag as the ref (for example --ref v0.3.4)." - exit 1 - fi - echo "Ref: ${GITHUB_REF}" - + # Only the staging script is taken from the repository. The payload comes from + # the release asset, so the dispatch ref does not decide what is published and + # a branch that moves cannot change the bytes. - name: Checkout uses: actions/checkout@v4 - name: Resolve and validate version id: version env: - INPUT_VERSION: ${{ inputs.version }} + INPUT_TAG: ${{ inputs.tag }} run: | set -euo pipefail - PKG_VERSION=$(node -p "require('./package.json').version") - - if [ -n "${INPUT_VERSION}" ]; then - VERSION="${INPUT_VERSION}" - else - VERSION="${PKG_VERSION}" - fi - - # Accept a stable x.y.z or a pre-release x.y.z- (beta1, rc1). - if ! printf '%s' "${VERSION}" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$'; then - echo "::error::Version '${VERSION}' is not x.y.z or x.y.z-." + # The tag is the single claim about what is being published. package.json + # is deliberately NOT consulted: the checkout is whatever ref was + # dispatched, so its version says nothing about the tagged payload. The + # artifact's own existence under this tag is the check that matters, and + # the download step below makes it. + if ! printf '%s' "${INPUT_TAG}" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$'; then + echo "::error::Tag '${INPUT_TAG}' is not a version tag. Expected v or v-, for example v0.4.1 or v0.5.0-beta1." exit 1 fi - # An explicit input that disagrees with package.json means the branch and - # the request are out of step; publishing either one would be a guess. - if [ "${VERSION}" != "${PKG_VERSION}" ]; then - echo "::error::Requested version '${VERSION}' does not match package.json ('${PKG_VERSION}')." - exit 1 - fi - - # The dispatch ref is a tag (enforced above), so its name is another - # independent claim about which version this is. If it disagrees with - # package.json, the tag was cut at the wrong commit — publishing either - # answer would be a guess, and wp.org tags are effectively permanent. - if [ "v${VERSION}" != "${GITHUB_REF_NAME}" ]; then - echo "::error::Tag '${GITHUB_REF_NAME}' does not match version '${VERSION}' from package.json. Expected tag 'v${VERSION}'." - exit 1 - fi + VERSION="${INPUT_TAG#v}" if printf '%s' "${VERSION}" | grep -q -- '-'; then CHANNEL=pre-release @@ -216,22 +195,51 @@ jobs: echo "Credential shape OK (not an authentication test - see the comment above this step)." - - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: npm + # No build, and therefore no Node. The payload is the artifact + # release-artifact.yml already built under this tag and gated behind PHPCS, + # PHPUnit and Plugin Check. Rebuilding here would publish bytes nothing had + # tested, which is the whole thing this avoids. + # + # A missing asset is a controlled failure, not something to work around: it + # means the release was never built or its artifact was removed, and this + # workflow has no business inventing a replacement. + - name: Download the tested release artifact + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + INPUT_TAG: ${{ inputs.tag }} + VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + + ASSET="airo-wp-${VERSION}.zip" + mkdir -p builds + + if ! gh release view "${INPUT_TAG}" --json tagName >/dev/null 2>&1; then + echo "::error::No GitHub Release ${INPUT_TAG}. release-artifact.yml creates the release and its artifact on merge to main; publish only after that has run." + exit 1 + fi + + if ! gh release view "${INPUT_TAG}" --json assets --jq '.assets[].name' | grep -qx "${ASSET}"; then + echo "::error::Release ${INPUT_TAG} carries no ${ASSET}. Dispatch release-artifact.yml with tag=${INPUT_TAG} to attach it, then retry." + exit 1 + fi + + gh release download "${INPUT_TAG}" --pattern "${ASSET}" --dir builds --clobber - - run: npm ci + # gh exits 0 on a pattern that matched nothing, so confirm the file rather + # than trusting the exit code. + if [ ! -f "builds/${ASSET}" ]; then + echo "::error::gh reported success but builds/${ASSET} is absent." + exit 1 + fi - # The zip is the canonical artifact: built from package.json "files", and - # the same one the pre-release workflow runs Plugin Check and the e2e - # matrix against. - - name: Build distributable zip - run: npm run build:zip + echo "Downloaded ${ASSET} ($(du -k "builds/${ASSET}" | awk '{print $1}') KiB) from ${INPUT_TAG}" - name: Stage SVN payload id: payload - run: bash .github/scripts/stage-svn-payload.sh builds/airo-wp.zip builds/svn-payload + env: + VERSION: ${{ steps.version.outputs.version }} + run: bash .github/scripts/stage-svn-payload.sh "builds/airo-wp-${VERSION}.zip" builds/svn-payload # deploy.sh evaluates its dry-run input as a shell command (`if $INPUT_DRY_RUN`), # so it must receive exactly `true` or `false`. Normalise here: anything that @@ -340,7 +348,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: airo-wp-${{ steps.version.outputs.version }} - path: builds/airo-wp.zip + path: builds/airo-wp-${{ steps.version.outputs.version }}.zip retention-days: 7 # Stable lane: rsync the payload into trunk and copy trunk to tags/X.Y.Z. @@ -553,15 +561,15 @@ jobs: runs-on: ubuntu-24.04 steps: - - name: Guard - dispatch ref must be a version tag - run: | - if [[ "${GITHUB_REF}" != refs/tags/v* ]]; then - echo "::error::Refusing to sync assets from '${GITHUB_REF}'. Dispatch this workflow with a v tag as the ref." - exit 1 - fi - - - name: Checkout + # Artwork cannot come from the release asset: .wordpress-org/ is deliberately + # excluded from the plugin zip, since it belongs in SVN assets/ and must never + # install on a user's site. So this job stays repo-driven — but it checks out + # the TAG rather than the dispatch ref, so the artwork published is the artwork + # at the released commit, matching the payload the publish job sends. + - name: Checkout the tagged commit uses: actions/checkout@v4 + with: + ref: refs/tags/${{ inputs.tag }} # Job-scoped: a standalone run skips the publish job, so its normalised flag # is not available here. Same fail-closed rule. diff --git a/.github/workflows/release-artifact.yml b/.github/workflows/release-artifact.yml index a2b5dd6..9118b47 100644 --- a/.github/workflows/release-artifact.yml +++ b/.github/workflows/release-artifact.yml @@ -1,6 +1,6 @@ name: Release Artifact -# Builds the distributable zip on every merge to main and attaches it to a GitHub +# Builds the distributable zip, tests it, and only then publishes it as a GitHub # Release tagged v. # # Why this exists: the repository ships no vendor/ and no dist/. Both are generated @@ -10,8 +10,21 @@ name: Release Artifact # that is actually installable, and is what the Git Updater headers in airo-wp.php # point at (Release Asset: true). # +# Pipeline — build once, test that build, publish that same build: +# +# build ──► check-plugin ──┐ +# lint ────────────────────┼──► publish +# unit ────────────────────┘ +# +# The zip is built exactly once and passed between jobs as a workflow artifact. +# lint and unit run against the repository (they need tests/ and the dev +# dependencies, neither of which ships in the zip) so they do not wait on the +# build. check-plugin and publish consume the built artifact, which is what makes +# the bytes published identical to the bytes tested. +# # It does NOT publish to WordPress.org. That is publish-plugin.yml, dispatched -# manually against the tag this workflow creates. +# manually against the tag this workflow creates, and it reuses this artifact +# rather than building its own. # # Note for anyone extending this: the tag created below is pushed with GITHUB_TOKEN, # and GitHub does not start workflow runs for events raised by GITHUB_TOKEN. So this @@ -23,28 +36,41 @@ on: push: branches: [main] workflow_dispatch: + inputs: + tag: + description: 'Existing v tag to build a missing artifact for (repair path). Leave empty to release the version in package.json on the dispatch ref.' + required: false + type: string concurrency: group: release-artifact cancel-in-progress: false -permissions: - contents: write - jobs: - release: - name: Build and publish release artifact + build: + name: Build artifact runs-on: ubuntu-24.04 - + permissions: + contents: read + outputs: + version: ${{ steps.version.outputs.version }} + prerelease: ${{ steps.version.outputs.prerelease }} + mode: ${{ steps.guard.outputs.mode }} steps: + # With a tag input we build that tag's tree, so a missing artifact can be + # rebuilt from exactly the code it should contain. Without one, the dispatch + # ref (or the pushed commit) is the source, as before. - name: Checkout uses: actions/checkout@v4 with: + ref: ${{ inputs.tag && format('refs/tags/{0}', inputs.tag) || github.ref }} fetch-tags: true fetch-depth: 0 - name: Resolve version id: version + env: + INPUT_TAG: ${{ inputs.tag }} run: | set -euo pipefail @@ -56,8 +82,16 @@ jobs: exit 1 fi - # A hyphen means a pre-release. Flagging the GitHub Release keeps it out of - # Git Updater's "latest", which is the same containment readme.txt's + # A tag input names a version too. If it disagrees with package.json at + # that tag, the tag was cut at the wrong commit and either answer would + # be a guess. + if [ -n "${INPUT_TAG}" ] && [ "${INPUT_TAG}" != "v${VERSION}" ]; then + echo "::error::Tag '${INPUT_TAG}' does not match package.json version '${VERSION}' at that tag. Expected 'v${VERSION}'." + exit 1 + fi + + # A hyphen means a pre-release. Flagging the GitHub Release keeps it out + # of Git Updater's "latest", which is the same containment readme.txt's # Stable tag gives us on WordPress.org. if printf '%s' "${VERSION}" | grep -q -- '-'; then PRERELEASE=true @@ -69,48 +103,69 @@ jobs: echo "prerelease=${PRERELEASE}" >> "$GITHUB_OUTPUT" echo "Version ${VERSION} (prerelease=${PRERELEASE})" - # Not an error: main can be pushed for reasons that do not bump the version - # (a docs commit, a revert, a re-run of this workflow). Only a NEW version is - # a new release, so an existing tag means there is nothing to do. - - name: Check whether this version is already released - id: gate + # An artifact that already exists is never overwritten. Publishing a second + # zip under a version users may already have installed would make "v0.4.1" + # mean two different payloads, and Git Updater serves whatever is attached. + # + # On this repository main advances ONLY by merging a release PR, so a push + # whose version is already released is not a no-op to shrug at — it means a + # release landed without its version being bumped. Fail loudly. + - name: Guard - refuse to overwrite an existing artifact + id: guard env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION: ${{ steps.version.outputs.version }} + INPUT_TAG: ${{ inputs.tag }} run: | set -euo pipefail - if gh api "repos/${GITHUB_REPOSITORY}/git/refs/tags/v${VERSION}" --silent 2>/dev/null; then - echo "release=false" >> "$GITHUB_OUTPUT" - echo "Tag v${VERSION} already exists — nothing to release." - echo "Tag \`v${VERSION}\` already exists; no new release." >> "$GITHUB_STEP_SUMMARY" - else - echo "release=true" >> "$GITHUB_OUTPUT" - echo "Tag v${VERSION} is free — will build and release." + TAG="v${VERSION}" + + if ! gh release view "${TAG}" --json tagName >/dev/null 2>&1; then + # Nothing published yet: the normal path. + echo "mode=create" >> "$GITHUB_OUTPUT" + echo "No release ${TAG} yet — will create it." + exit 0 + fi + + # The release exists. Whether that is an error depends on the asset. + ASSETS=$(gh release view "${TAG}" --json assets --jq '.assets[].name') + + if printf '%s\n' "${ASSETS}" | grep -qx "airo-wp-${VERSION}.zip"; then + echo "::error::Release ${TAG} already has asset airo-wp-${VERSION}.zip. Refusing to overwrite a published artifact — bump the version instead. To replace it deliberately, delete the asset first." + exit 1 + fi + + if [ -z "${INPUT_TAG}" ]; then + echo "::error::Release ${TAG} already exists but carries no artifact. That is a repair, so ask for it explicitly: dispatch this workflow with tag=${TAG}." + exit 1 fi + echo "mode=upload" >> "$GITHUB_OUTPUT" + echo "Release ${TAG} exists without an artifact — will attach one." + - uses: actions/setup-node@v4 - if: steps.gate.outputs.release == 'true' with: node-version: '20' cache: npm - run: npm ci - if: steps.gate.outputs.release == 'true' # Runs wp-scripts build (populates dist/) then build-zip.mjs, which runs # composer install --no-dev to generate vendor/ and the Strauss-prefixed # dependencies/, and zips per the package.json "files" allowlist. - name: Build distributable zip - if: steps.gate.outputs.release == 'true' run: npm run build:zip # The whole point of the release asset is that it is installable, so assert # that before publishing rather than discovering it from a bug report. A zip - # missing either of these installs as a dead plugin: no autoloader means - # airo-wp.php returns early, no dist/blocks/ means nothing registers. - - name: Verify the artifact is installable - if: steps.gate.outputs.release == 'true' + # missing either of the first two installs as a dead plugin: no autoloader + # means airo-wp.php returns early, no dist/blocks/ means nothing registers. + # + # These run here, in the job that produces the zip, because the same archive + # is what the test jobs consume and what publish attaches. Asserting it once + # at the source is what makes "tested" and "published" the same bytes. + - name: Verify the artifact is installable and free of test code env: VERSION: ${{ steps.version.outputs.version }} run: | @@ -151,38 +206,169 @@ jobs: exit 1 fi - echo "Artifact OK: autoloader present, ${block_count} block.json files." + # Test-support code must not reach users. It ships only because "files" + # allowlists includes/ wholesale, so build-zip.mjs strips it after + # packaging; this asserts that strip actually happened. Nothing in the + # plugin's runtime references any of it — verified by PHPUnit, Plugin + # Check and the E2E suite all passing against a zip without it. + test_code=$(printf '%s\n' "${manifest}" | grep -E '^airo-wp/(tests/|includes/Internal/Testing/|includes/Internal/DependencyManagement/TestingContainer\.php)' || true) + if [ -n "${test_code}" ]; then + echo "::error::${ZIP} contains test-support code, which must not ship:" + printf ' %s\n' "${test_code}" + exit 1 + fi + + echo "Artifact OK: autoloader present, ${block_count} block.json files, no test code." + + - name: Upload built artifact + uses: actions/upload-artifact@v4 + with: + name: airo-wp-zip + path: | + builds/airo-wp.zip + builds/airo-wp-${{ steps.version.outputs.version }}.zip + retention-days: 1 + if-no-files-found: error + + # lint and unit run against the repository, not the artifact: PHPCS needs the + # dev dependencies and PHPUnit needs tests/, and neither ships in the zip. They + # therefore do not depend on build, and run alongside it. + lint: + name: PHPCS + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag && format('refs/tags/{0}', inputs.tag) || github.ref }} + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + - run: npm ci + - run: npm run lint:php + + unit: + name: PHPUnit + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag && format('refs/tags/{0}', inputs.tag) || github.ref }} + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + - run: npm ci + - run: npm run test:unit:php + + # Plugin Check runs against the built artifact, installed into a real WordPress + # — the same zip publish attaches, downloaded rather than rebuilt. + check-plugin: + name: Plugin Check + needs: build + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag && format('refs/tags/{0}', inputs.tag) || github.ref }} + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + - run: npm ci + - name: Download built artifact + uses: actions/download-artifact@v4 + with: + name: airo-wp-zip + path: builds/ + - run: npm run plugin-check + - if: failure() + uses: actions/upload-artifact@v4 + with: + name: plugin-check-results + path: builds/plugin-check-results.txt + retention-days: 7 + + publish: + name: Publish release + needs: [build, lint, unit, check-plugin] + runs-on: ubuntu-24.04 + permissions: + contents: write + steps: + # gh release create tags the commit it is given, so the repository is needed + # for --target to resolve. + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag && format('refs/tags/{0}', inputs.tag) || github.ref }} + fetch-depth: 0 + + - name: Download built artifact + uses: actions/download-artifact@v4 + with: + name: airo-wp-zip + path: builds/ - name: Create tag and GitHub Release - if: steps.gate.outputs.release == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - VERSION: ${{ steps.version.outputs.version }} - PRERELEASE: ${{ steps.version.outputs.prerelease }} + VERSION: ${{ needs.build.outputs.version }} + PRERELEASE: ${{ needs.build.outputs.prerelease }} + MODE: ${{ needs.build.outputs.mode }} + TARGET_SHA: ${{ github.sha }} run: | set -euo pipefail ZIP="builds/airo-wp-${VERSION}.zip" + TAG="v${VERSION}" - # gh release create makes the tag at the current commit, so there is no - # separate tagging step to keep in sync with it. - FLAGS=(--title "v${VERSION}" --generate-notes) - if [ "${PRERELEASE}" = "true" ]; then - FLAGS+=(--prerelease) - fi - - # The authoritative permission check: this is the write itself. A 403 here - # almost always means the repository's workflow permissions are read-only, - # so say that instead of leaving a bare API error in the log. - if ! gh release create "v${VERSION}" "${ZIP}" \ - --target "${GITHUB_SHA}" \ - "${FLAGS[@]}" - then - echo "::error::Could not create release v${VERSION}. If this was a 403, GITHUB_TOKEN lacks contents: write - set Settings -> Actions -> General -> Workflow permissions to 'Read and write permissions'. No tag or release was created, so re-running after fixing it is safe." + if [ ! -f "${ZIP}" ]; then + echo "::error::${ZIP} is missing from the build artifact." exit 1 fi - echo "Created release v${VERSION} at ${GITHUB_SHA}" + if [ "${MODE}" = "upload" ]; then + # Repair path: the release exists without an artifact. Attach one; do + # not touch the release itself, whose notes and tag are already public. + if ! gh release upload "${TAG}" "${ZIP}"; then + echo "::error::Could not attach ${ZIP} to existing release ${TAG}." + exit 1 + fi + echo "Attached $(basename "${ZIP}") to existing release ${TAG}" + else + FLAGS=(--title "${TAG}" --generate-notes) + if [ "${PRERELEASE}" = "true" ]; then + FLAGS+=(--prerelease) + fi + + # --target is github.sha: the head of the dispatch ref, not the tag's + # commit. That is correct on the only path that consumes it — the + # push: main merge, where no tag exists yet and this is what creates + # it at the merged commit. A tag-input dispatch can only reach this + # branch when the git tag already exists but carries no GitHub + # Release (the build job's refs/tags/ checkout fails otherwise), + # and gh ignores --target for an existing tag. So it is never read + # from the wrong commit. + # + # The authoritative permission check: this is the write itself. A 403 + # here almost always means the repository's workflow permissions are + # read-only, so say that instead of leaving a bare API error in the log. + if ! gh release create "${TAG}" "${ZIP}" \ + --target "${TARGET_SHA}" \ + "${FLAGS[@]}" + then + echo "::error::Could not create release ${TAG}. If this was a 403, GITHUB_TOKEN lacks contents: write - set Settings -> Actions -> General -> Workflow permissions to 'Read and write permissions'. No tag or release was created, so re-running after fixing it is safe." + exit 1 + fi + echo "Created release ${TAG} at ${TARGET_SHA}" + fi { echo "### Release artifact" @@ -190,13 +376,16 @@ jobs: echo "| | |" echo "|---|---|" echo "| Version | \`${VERSION}\` |" - echo "| Tag | \`v${VERSION}\` |" + echo "| Tag | \`${TAG}\` |" echo "| Pre-release | ${PRERELEASE} |" + echo "| Mode | ${MODE} |" echo "| Asset | \`$(basename "${ZIP}")\` ($(du -k "${ZIP}" | awk '{print $1}') KiB) |" echo - echo "Publish to WordPress.org by dispatching \`publish-plugin.yml\` against \`v${VERSION}\`:" + echo "Tested before publishing: PHPCS, PHPUnit, and Plugin Check against this exact archive." + echo + echo "Publish to WordPress.org by dispatching \`publish-plugin.yml\` with this tag:" echo echo '```bash' - echo "gh workflow run publish-plugin.yml --ref v${VERSION} -f dry_run=true" + echo "gh workflow run publish-plugin.yml -f tag=${TAG} -f dry_run=true" echo '```' } >> "$GITHUB_STEP_SUMMARY" diff --git a/CHANGELOG.md b/CHANGELOG.md index bf186fb..5b307ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.4.1 + +- The download no longer carries test-only helper classes that were never used at runtime, making the plugin slightly smaller + ## 0.4.0 - Images can again be uploaded from raw file data, not only from a URL — an image with no publicly reachable address could not be uploaded at all diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 5d48357..35a4b71 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -137,28 +137,44 @@ by directory rather than enumerated. ## CI -`ci.yml` runs automatically on pull requests and pushes to `main`: +`ci.yml` runs automatically on every pull request: - **PHPCS** — WordPress coding standards - **PHPUnit** — PHP unit tests - **Plugin Check (PCP)** — WordPress.org plugin requirements check -All three must pass before merge. +All three must pass before merge. It does not run on pushes to `main`, because the +Release Artifact workflow re-runs the same checks there against the built artifact +before publishing anything — see *Releasing*. -`pre-release.yml` additionally runs on `release/*` pull requests and adds JS/SCSS -lint, the distributable zip build, and the full Playwright E2E matrix across the -supported PHP and WordPress versions. Those run before a release, not on every PR. +`pre-release.yml` is the full compatibility sweep: JS/SCSS lint on top of the above, +plus the Playwright E2E matrix across every supported PHP and WordPress version. It +runs on request only (`gh workflow run pre-release.yml`), not on pull requests. ## Releasing Releasing has two distinct stages, and only the first is automatic. **1. Merge to `main` produces the installable artifact.** The **Release Artifact** -workflow reads the version from `package.json`, builds the distributable zip, checks -it actually contains `vendor/autoload.php` and `dist/blocks/*/block.json`, then -creates tag `v` and a GitHub Release with the zip attached. A version whose -tag already exists is a no-op, not a failure, so an unrelated push to `main` does not -try to re-release. +workflow reads the version from `package.json` and runs one pipeline: + +1. **Build** the distributable zip, once, and check it is actually installable — it + must contain `vendor/autoload.php` and `dist/blocks/*/block.json`, and must not + contain `src/`, plugin-directory artwork or test-support code. +2. **Test** it — PHPCS and PHPUnit against the repository, and Plugin Check against + that built zip installed into a real WordPress. +3. **Publish** only if all of those pass: create tag `v` and a GitHub + Release with the zip attached. + +The zip is built once and handed between the jobs, so the archive that is tested is +byte-for-byte the archive that is published. + +A version whose release already carries an artifact is a **failure**, not a no-op. +On this repository `main` advances only by merging a release, so a push whose version +is already released means a release landed without its version being bumped — which +is worth stopping for rather than skipping past. If a release exists but its artifact +is missing, dispatch the workflow with `-f tag=v` to attach one; it will +never overwrite an artifact that is already published. That artifact matters because `vendor/` and `dist/` are generated at build time and never committed. GitHub's source zipball is therefore not an installable plugin, and @@ -167,10 +183,14 @@ the attached build is what both manual installs and `airo-wp.php`. **2. Publishing to WordPress.org is manual.** The **Publish Plugin** workflow is -dispatched by hand against a `v` tag and refuses any other ref. Tags, not -branches: a branch can move after CI went green, so dispatching from one could -publish commits nobody tested. A tag cannot, which also means re-running a publish -republishes byte-identical content. +dispatched by hand with the `tag` of a release from step 1. It builds nothing: the +payload is the artifact that workflow already built and tested, downloaded from that +release. So what reaches WordPress.org is the archive that passed Plugin Check, not a +second build that ought to match it, and re-running a publish republishes identical +bytes. If the tag has no artifact, the run fails and tells you to build one. + +Because the `tag` decides what is published, the ref you dispatch from does not +matter — dispatch from `main`. ### Version numbers @@ -201,13 +221,14 @@ not of access. ```bash gh workflow run publish-plugin.yml \ --repo godaddy-wordpress/airo-wp \ - --ref v0.3.5 \ + -f tag=v0.3.5 \ -f dry_run=true ``` The tag is the one created by the Release Artifact workflow when the release merged; -`gh release list` shows what is available. Dispatching a ref that is not a `v*` tag -fails immediately, before anything is built. +`gh release list` shows what is available, and only a tag whose release carries the +built artifact can be published. A tag that is missing, malformed, or has no artifact +fails immediately — and since nothing is ever built here, that costs seconds. Read the job summary: it lists the target Subversion tag, the full payload manifest, and the current `Stable tag` in `trunk`. When it looks right, re-dispatch @@ -238,12 +259,14 @@ moment they are committed, so they can be updated without releasing any code: ```bash gh workflow run publish-plugin.yml \ --repo godaddy-wordpress/airo-wp \ - --ref v0.3.5 \ + -f tag=v0.3.5 \ -f sync_assets=true -f dry_run=false ``` -`sync_assets` means *artwork only* — no code is built, no tag is created, and -`trunk` is not touched. +`sync_assets` means *artwork only* — no code is published, no tag is created, and +`trunk` is not touched. Artwork is read from the commit the `tag` points at, since +`.wordpress-org/` is deliberately excluded from the plugin zip and so cannot come +from the release artifact. If `.github/ASSETS_ARE_PLACEHOLDERS` is present, a real artwork sync is refused and only dry runs are allowed. It exists to stop unfinished artwork reaching the live diff --git a/README.md b/README.md index da9a495..d438f48 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ |---|---| | **Plugin name** | Airo WP AI Builder | | **Text domain** | `airo-wp` | -| **Version** | 0.4.0 | +| **Version** | 0.4.1 | | **Requires WordPress** | 6.9+ | | **Requires PHP** | 7.4+ | | **License** | [GPLv2 or later](https://www.gnu.org/licenses/gpl-2.0.html) | diff --git a/airo-wp.php b/airo-wp.php index e59c4fa..568a229 100644 --- a/airo-wp.php +++ b/airo-wp.php @@ -3,7 +3,7 @@ * Plugin Name: Airo WP AI Builder * Plugin URI: https://github.com/godaddy-wordpress/airo-wp * Description: MCP server and block pattern library for AI-powered site building. - * Version: 0.4.0 + * Version: 0.4.1 * Requires at least: 6.9 * Requires PHP: 7.4 * Author: GoDaddy @@ -47,7 +47,7 @@ defined( 'ABSPATH' ) || exit; if ( ! defined( 'AIRO_WP_VERSION' ) ) { - define( 'AIRO_WP_VERSION', '0.4.0' ); + define( 'AIRO_WP_VERSION', '0.4.1' ); } if ( ! defined( 'AIRO_WP_PLUGIN_FILE' ) ) { define( 'AIRO_WP_PLUGIN_FILE', __FILE__ ); diff --git a/package-lock.json b/package-lock.json index 5d4ca9a..ae0bb03 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "airo-wp", - "version": "0.4.0", + "version": "0.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "airo-wp", - "version": "0.4.0", + "version": "0.4.1", "devDependencies": { "@playwright/test": "^1.52.0", "@wordpress/e2e-test-utils-playwright": "^1.50.0", diff --git a/package.json b/package.json index a0f4f50..c086fef 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "airo-wp", - "version": "0.4.0", + "version": "0.4.1", "private": true, "files": [ "airo-wp.php", diff --git a/readme.txt b/readme.txt index 765c7de..035e9a9 100644 --- a/readme.txt +++ b/readme.txt @@ -4,7 +4,7 @@ Tags: airo, godaddy, mcp, ai, block-patterns Requires at least: 6.9 Tested up to: 7.1 Requires PHP: 7.4 -Stable tag: 0.4.0 +Stable tag: 0.4.1 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -113,6 +113,9 @@ Runtime Composer packages are namespace-prefixed with Strauss into `dependencies == Changelog == += 0.4.1 = +* The download no longer carries test-only helper classes that were never used at runtime, making the plugin slightly smaller + = 0.4.0 = * Images can again be uploaded from raw file data, not only from a URL — an image with no publicly reachable address could not be uploaded at all * Plugin tools now reject an empty plugin slug up front instead of failing partway through diff --git a/tests/e2e/setup/build-zip.mjs b/tests/e2e/setup/build-zip.mjs index 4b2c9b2..3d59070 100644 --- a/tests/e2e/setup/build-zip.mjs +++ b/tests/e2e/setup/build-zip.mjs @@ -131,6 +131,58 @@ console.log( 'build-zip.mjs: removing package.json from zip...' ); const zip = new AdmZip( TEMP_ZIP ); zip.deleteFile( 'airo-wp/package.json' ); + // Test-support code ships only because package.json "files" allowlists + // includes/ wholesale. Nothing in the plugin's runtime references any of it — + // the only referents are tests/, and phpcs.xml already excludes + // includes/Internal/Testing/ — so it installs on every user's site, unused. + // + // Dropped here rather than by narrowing the "files" allowlist, because the + // files must stay in the repo: PHPUnit's container tests use these fixtures. + // The allowlist cannot express "ship includes/ except this subtree". + // + // This list is duplicated in the release tooling's other zip builder. Change + // both together: if they drift, the archive that gets tested stops being the + // archive that gets published, which is the one property this strip exists to + // preserve. + const TEST_ONLY_PREFIXES = [ + 'airo-wp/includes/Internal/Testing/', + 'airo-wp/includes/Internal/DependencyManagement/TestingContainer.php', + ]; + + const testOnly = zip + .getEntries() + .map( ( entry ) => entry.entryName ) + .filter( ( name ) => + TEST_ONLY_PREFIXES.some( ( prefix ) => name.startsWith( prefix ) ) + ); + + for ( const name of testOnly ) { + zip.deleteFile( name ); + } + + console.log( + `build-zip.mjs: removed ${ testOnly.length } test-support entries from zip` + ); + + // AdmZip silently ignores a deleteFile for a name it does not hold, so assert + // the removal actually happened rather than trusting the call. + const stillThere = zip + .getEntries() + .map( ( entry ) => entry.entryName ) + .filter( ( name ) => + TEST_ONLY_PREFIXES.some( ( prefix ) => name.startsWith( prefix ) ) + ); + + if ( stillThere.length > 0 ) { + console.error( + `build-zip.mjs: test-support code survived removal:\n ${ stillThere.join( + '\n ' + ) }` + ); + rmSync( TEMP_ZIP, { force: true } ); + process.exit( 1 ); + } + // .wordpress-org/ holds the WordPress.org plugin-directory art (banner, icon, // screenshots). Those belong in SVN assets/, never inside the plugin users // install. It is absent from package.json "files" so it is excluded already;