diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..5fa89b6 --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7837613..0c9475c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,32 +7,61 @@ 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 @@ -40,20 +69,22 @@ jobs: 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 @@ -61,19 +92,21 @@ jobs: --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 }} @@ -81,20 +114,22 @@ jobs: --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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 54c5444..4045b76 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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}" diff --git a/bin/build.php b/bin/build.php index 8556e9a..4dd37d3 100644 --- a/bin/build.php +++ b/bin/build.php @@ -1,52 +1,108 @@ 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" ); diff --git a/docs/CI_CD_SECURE_RUNBOOK.md b/docs/CI_CD_SECURE_RUNBOOK.md new file mode 100644 index 0000000..5574d3c --- /dev/null +++ b/docs/CI_CD_SECURE_RUNBOOK.md @@ -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/.json --output=output/release-candidate +node ..\zipquantum-app\scripts\release\verify-candidate.mjs --manifest=output/release-candidate/.manifest.json --archive=output/release-candidate/.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. diff --git a/scripts/secret-scan.mjs b/scripts/secret-scan.mjs new file mode 100644 index 0000000..4351297 --- /dev/null +++ b/scripts/secret-scan.mjs @@ -0,0 +1,37 @@ +#!/usr/bin/env node + +import { execFileSync, spawnSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; + +const gitExecutable = process.env.ZQ_GIT + || (process.platform === 'win32' && existsSync('C:\\Program Files\\Git\\cmd\\git.exe') + ? 'C:\\Program Files\\Git\\cmd\\git.exe' + : 'git'); +const forbiddenPaths = [/(^|\/)\.env(?:\.|$)/i, /(^|\/)(?:id_rsa|id_ed25519|credentials)(?:\.|$)/i, /\.(?:pem|p12|pfx|key|jks|keystore|sql|sqlite|sqlite3)$/i]; +const patterns = [ + '-----BEGIN (RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----', + '(AKIA|ASIA)[A-Z0-9]{16}', + '(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{30,}', + '(sk|rk)_live_[A-Za-z0-9]{16,}', + 'xox[baprs]-[A-Za-z0-9-]{20,}', + 'eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}', + '(api[_-]?key|api[_-]?secret|client[_-]?secret|password|passwd|secret|token)[[:space:]]*[:=][[:space:]]*[A-Za-z0-9+/_=-]{24,}', +]; +const git = (args) => execFileSync(gitExecutable, args, { encoding: 'utf8', maxBuffer: 128 * 1024 * 1024 }); +const versionable = git(['ls-files', '-z', '--cached', '--others', '--exclude-standard']).split('\0').filter(Boolean); +const findings = versionable.filter((path) => !/(^|\/)\.env\.example$/i.test(path) && forbiddenPaths.some((pattern) => pattern.test(path))); +const result = spawnSync(gitExecutable, ['grep', '--untracked', '--exclude-standard', '-I', '-l', '-E', '-e', patterns.map((pattern) => `(${pattern})`).join('|'), '--', '.', ':(exclude)scripts/secret-scan.mjs'], { + encoding: 'utf8', + maxBuffer: 128 * 1024 * 1024, +}); +if (result.status !== 0 && result.status !== 1) { + console.error('Secret scan could not inspect the Git worktree.'); + process.exit(2); +} +findings.push(...result.stdout.split(/\r?\n/).filter(Boolean)); +if (findings.length) { + console.error('Secret scan failed closed. Matched values are redacted; only paths follow.'); + for (const path of [...new Set(findings)].sort()) console.error(path); + process.exit(1); +} +console.log(`Secret scan passed (${versionable.length} tracked/untracked versionable files; values never printed).`);