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
16 changes: 16 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
* text=auto

*.php text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
*.js text eol=lf
*.json text eol=lf
*.md text eol=lf
*.txt text eol=lf
*.pot text eol=lf
*.po text eol=lf

*.png binary
*.jpg binary
*.jpeg binary
*.zip binary
63 changes: 49 additions & 14 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,94 +7,129 @@ on:
permissions:
contents: read

concurrency:
group: wordpress-ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
security:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "22"
- name: Fail-closed secret and sensitive-file gate
run: node scripts/secret-scan.mjs
- name: Reject whitespace and merge-marker damage
run: git diff --check

quality:
needs: security
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: shivammathur/setup-php@bf6b4fbd49ca58e4608c9c89fba0b8d90bd2a39f # 2.35.5
with:
php-version: "8.2"
extensions: zip
coverage: none
- uses: ramsey/composer-install@v3
- uses: ramsey/composer-install@a8d0d959dab41457692a5e2041bd9b757a119e3f # 3.2.1
- run: composer validate --strict
- run: composer audit --locked --no-interaction
- run: composer lint
- run: composer test
- run: php bin/build.php
- run: unzip -t dist/zipquantum-smart-links.zip
- name: Verify tracked-only package manifest
run: |
test "$(sha256sum dist/zipquantum-smart-links.zip | awk '{print $1}')" = "$(awk 'NR==1 {print $1}' dist/zipquantum-smart-links.zip.sha256)"
(cd dist && sha256sum -c zipquantum-smart-links.SHA256SUMS)
php -r '$m=json_decode(file_get_contents("dist/zipquantum-smart-links.manifest.json"),true,32,JSON_THROW_ON_ERROR); if (($m["dirty_override"]??true) || ($m["file_count"]??0)<1) exit(1);'

php-syntax:
needs: security
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
php: ["7.4", "8.0", "8.2", "8.3"]
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: shivammathur/setup-php@bf6b4fbd49ca58e4608c9c89fba0b8d90bd2a39f # 2.35.5
with:
php-version: ${{ matrix.php }}
coverage: none
- name: Lint production PHP
run: find . -path './vendor' -prune -o -path './tests' -prune -o -name '*.php' -print0 | xargs -0 -n1 php -l

wordpress-matrix:
needs: security
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
wordpress: ["6.9.7", "7.0.4", "7.1"]
php: ["7.4", "8.0", "8.2", "8.3"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "22"
- name: Activate on WordPress ${{ matrix.wordpress }} and PHP ${{ matrix.php }}
run: >-
npx --yes @wp-playground/cli@latest run-blueprint
npx --yes @wp-playground/cli@3.1.53 run-blueprint
--wp=${{ matrix.wordpress }}
--php=${{ matrix.php }}
--blueprint=tests/blueprints/activation.json
--mount-dir "$GITHUB_WORKSPACE" /wordpress/wp-content/plugins/zipquantum-smart-links
--verbosity=quiet

woocommerce-matrix:
needs: security
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
blueprint: ["woocommerce-10.9.4.json", "woocommerce-11.0.1.json"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "22"
- name: Load the conditional WooCommerce integration
run: >-
npx --yes @wp-playground/cli@latest run-blueprint
npx --yes @wp-playground/cli@3.1.53 run-blueprint
--wp=7.1
--php=8.3
--blueprint=tests/blueprints/${{ matrix.blueprint }}
--mount-dir "$GITHUB_WORKSPACE" /wordpress/wp-content/plugins/zipquantum-smart-links
--verbosity=quiet

forward-compatibility:
needs: security
runs-on: ubuntu-latest
timeout-minutes: 20
continue-on-error: true
strategy:
fail-fast: false
matrix:
php: ["8.4", "8.5"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "22"
- name: Exercise WordPress 7.1 on PHP ${{ matrix.php }}
run: >-
npx --yes @wp-playground/cli@latest run-blueprint
npx --yes @wp-playground/cli@3.1.53 run-blueprint
--wp=7.1
--php=${{ matrix.php }}
--blueprint=tests/blueprints/activation.json
Expand Down
58 changes: 43 additions & 15 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,37 +8,65 @@ on:
permissions:
contents: write

concurrency:
group: wordpress-release-${{ github.ref }}
cancel-in-progress: false

jobs:
release:
runs-on: ubuntu-latest
timeout-minutes: 30
environment: wordpress-release
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "22"
- name: Fail-closed secret and sensitive-file gate
run: node scripts/secret-scan.mjs
- uses: shivammathur/setup-php@bf6b4fbd49ca58e4608c9c89fba0b8d90bd2a39f # 2.35.5
with:
php-version: '8.2'
extensions: zip
coverage: none
- uses: ramsey/composer-install@v3
- uses: ramsey/composer-install@a8d0d959dab41457692a5e2041bd9b757a119e3f # 3.2.1
- run: composer validate --strict
- run: composer audit --locked --no-interaction
- run: composer lint
- run: composer test
- name: Match release tag, plugin header, and WordPress stable tag
env:
RELEASE_TAG: ${{ github.ref_name }}
run: |
php -r '
$expected = preg_replace("/^v/", "", getenv("RELEASE_TAG"));
preg_match("/^ \\* Version:\\s*(\\S+)/m", file_get_contents("zipquantum-smart-links.php"), $plugin);
preg_match("/^Stable tag:\\s*(\\S+)/mi", file_get_contents("readme.txt"), $readme);
if (($plugin[1] ?? "") !== $expected || ($readme[1] ?? "") !== $expected) {
fwrite(STDERR, "Release tag, plugin Version, and Stable tag must match.\\n");
exit(65);
}'
- run: php bin/build.php
- run: unzip -t dist/zipquantum-smart-links.zip
- name: Generate checksum
run: sha256sum dist/zipquantum-smart-links.zip > dist/zipquantum-smart-links.zip.sha256
- name: Verify generated checksum and clean-tree manifest
run: |
archive_hash="$(sha256sum dist/zipquantum-smart-links.zip | awk '{print $1}')"
expected_hash="$(awk 'NR==1 {print $1}' dist/zipquantum-smart-links.zip.sha256)"
test "$archive_hash" = "$expected_hash"
(cd dist && sha256sum -c zipquantum-smart-links.SHA256SUMS)
php -r '$m=json_decode(file_get_contents("dist/zipquantum-smart-links.manifest.json"),true,32,JSON_THROW_ON_ERROR); if (($m["dirty_override"]??true) || ($m["file_count"]??0)<1) exit(1);'
- name: Publish GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: |
if gh release view "${GITHUB_REF_NAME}" >/dev/null 2>&1; then
gh release upload "${GITHUB_REF_NAME}" \
dist/zipquantum-smart-links.zip \
dist/zipquantum-smart-links.zip.sha256 \
--clobber
else
gh release create "${GITHUB_REF_NAME}" \
dist/zipquantum-smart-links.zip \
dist/zipquantum-smart-links.zip.sha256 \
--generate-notes \
--title "ZipQuantum for WordPress ${GITHUB_REF_NAME}"
echo "Release ${GITHUB_REF_NAME} already exists; refusing to replace immutable assets." >&2
exit 65
fi
gh release create "${GITHUB_REF_NAME}" \
dist/zipquantum-smart-links.zip \
dist/zipquantum-smart-links.zip.sha256 \
dist/zipquantum-smart-links.SHA256SUMS \
dist/zipquantum-smart-links.manifest.json \
--generate-notes \
--title "ZipQuantum for WordPress ${GITHUB_REF_NAME}"
98 changes: 77 additions & 21 deletions bin/build.php
Original file line number Diff line number Diff line change
@@ -1,52 +1,108 @@
<?php
/**
* Build a clean WordPress.org-ready ZIP.
* Build a clean, tracked-only WordPress.org ZIP and integrity manifest.
*/

$root = dirname( __DIR__ );
$dist = $root . DIRECTORY_SEPARATOR . 'dist';
$zip_path = $dist . DIRECTORY_SEPARATOR . 'zipquantum-smart-links.zip';
$root = dirname( __DIR__ );
$dist = $root . DIRECTORY_SEPARATOR . 'dist';
$zip_path = $dist . DIRECTORY_SEPARATOR . 'zipquantum-smart-links.zip';
$manifest_path = $dist . DIRECTORY_SEPARATOR . 'zipquantum-smart-links.manifest.json';
$sha_path = $zip_path . '.sha256';
$sums_path = $dist . DIRECTORY_SEPARATOR . 'zipquantum-smart-links.SHA256SUMS';

if ( ! extension_loaded( 'zip' ) ) {
fwrite( STDERR, "The PHP zip extension is required.\n" );
exit( 1 );
}

function zq_git( array $arguments, string $root ): string {
$command = array_merge( array( 'git' ), $arguments );
$spec = array(
0 => array( 'pipe', 'r' ),
1 => array( 'pipe', 'w' ),
2 => array( 'pipe', 'w' ),
);
$process = proc_open( $command, $spec, $pipes, $root );
if ( ! is_resource( $process ) ) {
fwrite( STDERR, "Unable to start Git.\n" );
exit( 1 );
}
fclose( $pipes[0] );
$stdout = stream_get_contents( $pipes[1] );
$stderr = stream_get_contents( $pipes[2] );
fclose( $pipes[1] );
fclose( $pipes[2] );
$status = proc_close( $process );
if ( 0 !== $status ) {
fwrite( STDERR, "Git command failed: " . trim( $stderr ) . "\n" );
exit( 1 );
}
return $stdout;
}

$status = zq_git( array( 'status', '--porcelain=v1', '--untracked-files=all' ), $root );
if ( '' !== trim( $status ) && '1' !== getenv( 'ZQ_ALLOW_DIRTY_BUILD' ) ) {
fwrite( STDERR, "Refusing to package a dirty worktree. Commit reviewed files first, or set ZQ_ALLOW_DIRTY_BUILD=1 for a non-release local test.\n" );
exit( 1 );
}

if ( ! is_dir( $dist ) && ! mkdir( $dist, 0777, true ) && ! is_dir( $dist ) ) {
fwrite( STDERR, "Could not create dist directory.\n" );
exit( 1 );
}

$excluded_roots = array( '.git', '.github', '.playwright-cli', 'bin', 'dist', 'docs', 'output', 'tests', 'vendor', 'wordpress-org-assets' );
$excluded_files = array( '.gitignore', '.phpunit.result.cache', 'composer.json', 'composer.lock', 'phpcs.xml.dist', 'phpunit.xml.dist', 'README.md' );
$zip = new ZipArchive();
$excluded_roots = array( '.github', 'bin', 'dist', 'docs', 'output', 'scripts', 'tests', 'wordpress-org-assets' );
$excluded_files = array( '.gitignore', 'composer.json', 'composer.lock', 'phpcs.xml.dist', 'phpunit.xml.dist', 'README.md' );
$tracked = array_values( array_filter( explode( "\0", zq_git( array( 'ls-files', '-z', '--' ), $root ) ) ) );
sort( $tracked, SORT_STRING );

$zip = new ZipArchive();
if ( true !== $zip->open( $zip_path, ZipArchive::CREATE | ZipArchive::OVERWRITE ) ) {
fwrite( STDERR, "Could not create ZIP.\n" );
exit( 1 );
}

$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator( $root, FilesystemIterator::SKIP_DOTS ),
RecursiveIteratorIterator::LEAVES_ONLY
);

foreach ( $iterator as $file ) {
if ( ! $file->isFile() ) {
continue;
}
$relative = str_replace( '\\', '/', substr( $file->getPathname(), strlen( $root ) + 1 ) );
$release_files = array();
foreach ( $tracked as $relative ) {
$relative = str_replace( '\\', '/', $relative );
$first = strtok( $relative, '/' );
if ( in_array( $first, $excluded_roots, true ) || in_array( $relative, $excluded_files, true ) ) {
continue;
}
$zip->addFile( $file->getPathname(), 'zipquantum-smart-links/' . $relative );
$source = $root . DIRECTORY_SEPARATOR . str_replace( '/', DIRECTORY_SEPARATOR, $relative );
if ( ! is_file( $source ) ) {
fwrite( STDERR, "Tracked release file is missing: {$relative}\n" );
$zip->close();
exit( 1 );
}
$archive_path = 'zipquantum-smart-links/' . $relative;
$release_files[ $archive_path ] = hash_file( 'sha256', $source );
$zip->addFile( $source, $archive_path );
}

$zip->close();
$size = filesize( $zip_path );
if ( $size > 10 * 1024 * 1024 ) {
fwrite( STDERR, "ZIP exceeds the 10 MB WordPress.org limit.\n" );
if ( false === $size || $size > 10 * 1024 * 1024 ) {
fwrite( STDERR, "ZIP exceeds the 10 MB WordPress.org limit or its size is unreadable.\n" );
exit( 1 );
}

fwrite( STDOUT, $zip_path . ' (' . $size . " bytes)\n" );
$manifest = array(
'schema' => 1,
'component' => 'wordpress-plugin',
'git_commit' => trim( zq_git( array( 'rev-parse', 'HEAD' ), $root ) ),
'dirty_override' => '' !== trim( $status ),
'archive' => basename( $zip_path ),
'archive_sha256' => hash_file( 'sha256', $zip_path ),
'file_count' => count( $release_files ),
'release_files' => $release_files,
);
file_put_contents( $manifest_path, json_encode( $manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR ) . "\n" );
file_put_contents( $sha_path, $manifest['archive_sha256'] . ' ' . basename( $zip_path ) . "\n" );
file_put_contents(
$sums_path,
$manifest['archive_sha256'] . ' ' . basename( $zip_path ) . "\n" .
hash_file( 'sha256', $manifest_path ) . ' ' . basename( $manifest_path ) . "\n"
);

fwrite( STDOUT, $zip_path . ' (' . $size . ' bytes, ' . count( $release_files ) . " tracked files)\n" );
27 changes: 27 additions & 0 deletions docs/CI_CD_SECURE_RUNBOOK.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# WordPress CI/CD notes

The cross-project operating procedure is in `../zipquantum-app/docs/CI_CD_SECURE_RUNBOOK.md`.

WordPress CI now runs a redacting secret/path gate before quality and compatibility jobs. The package builder refuses a dirty tree and includes only Git-tracked production files. It emits:

- `dist/zipquantum-smart-links.zip`;
- `dist/zipquantum-smart-links.zip.sha256`;
- `dist/zipquantum-smart-links.SHA256SUMS` for the ZIP and manifest;
- `dist/zipquantum-smart-links.manifest.json` with the commit, ZIP hash and hashes of packaged files.

`ZQ_ALLOW_DIRTY_BUILD=1` is for a local non-release test only. The manifest records the override and release CI rejects it.

The release workflow uses a protected `wordpress-release` environment, refuses to overwrite an existing GitHub Release, and requires the `v*` tag to match both the plugin `Version` header and the WordPress `Stable tag`. Configure required reviewers and protected `v*` tags in GitHub before pushing any release tag; the workflow file cannot enforce those repository settings by itself.

The repository `.gitattributes` fixes source and workflow files to LF so PHPCS and Linux CI do not depend on a developer's `core.autocrlf` setting. Existing Windows worktrees may need a fresh checkout or an explicitly reviewed renormalization; do not mix that mechanical rewrite into a functional release commit.

The installable ZIP and a live-runtime hotfix artefact are separate deliverables. For a reviewed runtime allowlist, create a checked-in plan under `scripts/release/plans/`, then run the shared tools from this repository so Git provenance resolves against WordPress:

```powershell
node ..\zipquantum-app\scripts\release\build-candidate.mjs --plan=scripts/release/plans/<plan>.json --output=output/release-candidate
node ..\zipquantum-app\scripts\release\verify-candidate.mjs --manifest=output/release-candidate/<release>.manifest.json --archive=output/release-candidate/<release>.tar.gz
```

The plan must use `component: "wordpress-plugin"`, target only the plugin runtime, carry a SHA-256 precondition (or explicit `absent`) for every file, and declare HTTPS HTTP/API/visual smokes. Promotion remains an explicit, separately reviewed manual action.

GitHub Release and WordPress.org are distribution channels, not automatic deployment to `/home/supportproxi/public_html/zq.tn`. A live promotion still requires runtime precondition hashes, a current database backup outside docroot, staging activation, HTTP/API checks, desktop/mobile visual review, cache verification and a tested rollback.
Loading
Loading