Skip to content
Closed
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
111 changes: 111 additions & 0 deletions bin/class-scf-since-resolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
<?php
/**
* Resolve SCF @since placeholders.
*
* @package wordpress/secure-custom-fields
*/

namespace WordPress\SCF\Scripts;

/**
* Resolves release-time @since placeholders in source files.
*
* Contributors write `@since SCF {NEXT_MAJOR_VERSION}` for new APIs so the
* version does not have to be guessed before the release.
*/
class Since_Resolver {
/**
* Placeholder used for new SCF APIs.
*
* @var string
*/
const PLACEHOLDER = '{NEXT_MAJOR_VERSION}';

/**
* Source directories to scan.
*
* @var string[]
*/
const SOURCE_DIRECTORIES = array( 'includes', 'src', 'assets/src' );

/**
* Resolve placeholders in source files.
*
* @param string $version Release version.
* @param string $root Repository root.
* @return string[] Paths of files that were changed.
* @throws \RuntimeException When a source file cannot be written.
*/
public function resolve( $version, $root ) {
$changed = array();

foreach ( self::SOURCE_DIRECTORIES as $directory ) {
$path = $root . '/' . $directory;
if ( ! is_dir( $path ) ) {
continue;
}

$iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( $path ) );
foreach ( $iterator as $file ) {
if ( ! $file->isFile() || ! $this->is_supported_file( $file->getFilename() ) ) {
continue;
}

if ( $this->resolve_file( $file->getPathname(), $version ) ) {
$changed[] = $file->getPathname();
}
}
}

return $changed;
}

/**
* Resolve placeholders in one file.
*
* Only the placeholder sitting in an `@since` annotation is replaced, so
* unrelated text is never touched.
*
* @param string $path File path.
* @param string $version Release version.
* @return int Number of replacements.
* @throws \RuntimeException When the file cannot be written.
*/
private function resolve_file( $path, $version ) {
$content = file_get_contents( $path );
if ( false === $content || false === strpos( $content, self::PLACEHOLDER ) ) {
return 0;
}

$updated = preg_replace_callback(
'/@since\s+(?:SCF\s+)?' . preg_quote( self::PLACEHOLDER, '/' ) . '/',
function () use ( $version ) {
return '@since SCF ' . $version;
},
$content,
-1,
$count
);

if ( null === $updated || 0 === $count ) {
return 0;
}

if ( false === file_put_contents( $path, $updated ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Release tooling runs outside WordPress.
// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- CLI error output, not rendered HTML.
throw new \RuntimeException( 'Could not write resolved @since annotations to ' . $path );
}

return $count;
}

/**
* Check whether a file can contain a source docblock.
*
* @param string $filename File name.
* @return bool Whether the file is supported.
*/
private function is_supported_file( $filename ) {
return (bool) preg_match( '/\.(php|js|jsx|ts|tsx)$/', $filename );
}
}
48 changes: 48 additions & 0 deletions bin/prepare-release.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

// Ensure we're in the right directory.
chdir( dirname( __DIR__ ) );
require_once __DIR__ . '/class-scf-since-resolver.php';

// Check if required PHP functions are available.
$required_functions = array( 'exec', 'passthru' );
Expand Down Expand Up @@ -75,6 +76,12 @@ public function run() {
exit( 1 );
}

$resolved_sources = $this->resolve_since_annotations( $new_version );
if ( $resolved_sources ) {
$this->generate_docs();
$this->commit_since_annotations( $new_version, $resolved_sources );
}

$this->update_version( $new_version );
$this->commit_version( $new_version );

Expand Down Expand Up @@ -260,6 +267,47 @@ private function get_changelog( $version ) {
return '';
}

/**
* Resolve release-time @since placeholders.
*
* @param string $version Release version.
* @return string[] Paths of files that were changed.
*/
private function resolve_since_annotations( $version ) {
$resolver = new Since_Resolver();
$changed = $resolver->resolve( $version, dirname( __DIR__ ) );

// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo 'Resolved @since placeholders in ' . count( $changed ) . " file(s) to {$version}.\n";

return $changed;
}

/**
* Commit resolved @since annotations and their regenerated documentation.
*
* @param string $version Version the annotations were resolved to.
* @param string[] $sources Source files that were changed.
*/
private function commit_since_annotations( $version, $sources ) {
$paths = array_merge( $sources, array( 'docs/code-reference', 'docs/bin/manifest.json' ) );
passthru( 'git add ' . implode( ' ', array_map( 'escapeshellarg', $paths ) ), $return );
if ( 0 !== $return ) {
exit( $return );
}

exec( 'git diff --cached --quiet', $output, $status );
if ( 0 === $status ) {
return;
}

echo "Committing resolved @since annotations...\n";
passthru( "git commit -m 'Resolve @since placeholders for {$version}'", $return );
if ( 0 !== $return ) {
exit( $return );
}
}

/**
* Update version in plugin file
*
Expand Down
1 change: 1 addition & 0 deletions docs/contributing/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,4 @@ Releases are **not** deployed automatically — there is no GitHub Action that p
- Write unit tests for new features
- Document all changes
- Keep pull requests focused
- For new SCF APIs, use `@since SCF {NEXT_MAJOR_VERSION}`. The release preparation script replaces this placeholder with the release version.
1 change: 1 addition & 0 deletions docs/contributing/releases.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ This will run an interactive script that will:
- Generate documentation, verify generated schemas, and update translations
- Commit these changes to the current branch
- Prompt the user for the new version number and update it accordingly in `secure-custom-fields.php`
- Replace `@since SCF {NEXT_MAJOR_VERSION}` placeholders with the release version and regenerate the reference documentation
- Offer the user to update the stable tag in `readme.txt`
- Offer the user to create a PR (so the changes can be merged back to `trunk`)

Expand Down
25 changes: 14 additions & 11 deletions tests/e2e/fixtures.js
Original file line number Diff line number Diff line change
Expand Up @@ -144,22 +144,25 @@ const test = wpTest.extend( {
'role=button[name="View"i]'
);

if (
const hasViewButton =
( await viewButton.count() ) > 0 &&
( await viewButton.first().isVisible() )
) {
return editor.openPreviewPage();
}
( await viewButton.first().isVisible() );

await editorTopBar
.locator( 'role=button[name="Preview"i]' )
.click();
await ( hasViewButton
? viewButton.first()
: editorTopBar.locator( 'role=button[name="Preview"i]' )
).click();

// WordPress trunk renamed the menu item to
// "Preview (opens in a new tab)", so match on the shared
// "Preview ... new tab" text instead of the exact label.
const [ previewPage ] = await Promise.all( [
context.waitForEvent( 'page' ),
page.click(
'role=menuitem[name=/Preview in new tab/i]'
),
page
.getByRole( 'menuitem', {
name: /Preview.*new tab/i,
} )
.click(),
] );

return previewPage;
Expand Down
130 changes: 130 additions & 0 deletions tests/php/docs/SCFSinceResolverTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
<?php
/**
* Tests for SCF @since placeholder resolution.
*
* @package wordpress/secure-custom-fields
*/

use WorDBless\BaseTestCase;
use WordPress\SCF\Scripts\Since_Resolver;

require_once dirname( __DIR__, 3 ) . '/bin/class-scf-since-resolver.php';

/**
* Tests for the SCF @since placeholder resolver.
*/
class Test_SCF_Since_Resolver extends BaseTestCase {
/**
* Temporary source directory.
*
* @var string
*/
private $source_dir;

/**
* Remove temporary files after each test.
*/
public function tear_down() {
if ( $this->source_dir && is_dir( $this->source_dir ) ) {
$this->remove_directory( $this->source_dir );
}

parent::tear_down();
}

/**
* Resolves SCF annotations and leaves legacy and resolved tags untouched.
*/
public function test_resolves_placeholders_without_changing_existing_tags() {
$this->source_dir = sys_get_temp_dir() . '/scf-since-' . uniqid();
mkdir( $this->source_dir . '/includes', 0777, true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir -- Test fixture setup.
file_put_contents( // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Test fixture setup.
$this->source_dir . '/includes/example.php',
"<?php\n/**\n * @since SCF {NEXT_MAJOR_VERSION}\n * @since ACF 6.1\n * @since SCF 6.9.5\n */\n"
);

$changed = ( new Since_Resolver() )->resolve( '7.0.0', $this->source_dir );
$content = file_get_contents( $this->source_dir . '/includes/example.php' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Test fixture assertion.

$this->assertCount( 1, $changed );
$this->assertStringContainsString( '@since SCF 7.0.0', $content );
$this->assertStringContainsString( '@since ACF 6.1', $content );
$this->assertStringContainsString( '@since SCF 6.9.5', $content );
}

/**
* Handles the documented placeholder form without the SCF prefix.
*/
public function test_resolves_placeholder_without_scf_prefix() {
$this->source_dir = sys_get_temp_dir() . '/scf-since-' . uniqid();
mkdir( $this->source_dir . '/src', 0777, true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir -- Test fixture setup.
file_put_contents( $this->source_dir . '/src/example.js', '/** @since {NEXT_MAJOR_VERSION} */' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Test fixture setup.

$changed = ( new Since_Resolver() )->resolve( '7.0.0', $this->source_dir );
$content = file_get_contents( $this->source_dir . '/src/example.js' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Test fixture assertion.

$this->assertCount( 1, $changed );
$this->assertStringContainsString( '@since SCF 7.0.0', $content );
}

/**
* Leaves placeholder text that is not an annotation alone.
*/
public function test_ignores_placeholder_outside_annotations() {
$this->source_dir = sys_get_temp_dir() . '/scf-since-' . uniqid();
mkdir( $this->source_dir . '/includes', 0777, true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir -- Test fixture setup.
file_put_contents( // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Test fixture setup.
$this->source_dir . '/includes/example.php',
"<?php\n\$label = 'SCF {NEXT_MAJOR_VERSION}';\n"
);

$changed = ( new Since_Resolver() )->resolve( '7.0.0', $this->source_dir );
$content = file_get_contents( $this->source_dir . '/includes/example.php' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Test fixture assertion.

$this->assertSame( array(), $changed );
$this->assertStringContainsString( "'SCF {NEXT_MAJOR_VERSION}'", $content );
}

/**
* Ignores unsupported file types.
*/
public function test_ignores_unsupported_files() {
$this->source_dir = sys_get_temp_dir() . '/scf-since-' . uniqid();
mkdir( $this->source_dir . '/includes', 0777, true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir -- Test fixture setup.
file_put_contents( $this->source_dir . '/includes/example.txt', '@since SCF {NEXT_MAJOR_VERSION}' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Test fixture setup.

$this->assertSame( array(), ( new Since_Resolver() )->resolve( '7.0.0', $this->source_dir ) );
}

/**
* Returns no changed files when nothing needs resolving.
*/
public function test_reports_no_changes_without_placeholders() {
$this->source_dir = sys_get_temp_dir() . '/scf-since-' . uniqid();
mkdir( $this->source_dir . '/src', 0777, true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir -- Test fixture setup.
file_put_contents( $this->source_dir . '/src/example.js', '/** @since SCF 6.9.5 */' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Test fixture setup.

$this->assertSame( array(), ( new Since_Resolver() )->resolve( '7.0.0', $this->source_dir ) );
}

/**
* Recursively remove a temporary directory.
*
* @param string $dir Directory path.
*/
private function remove_directory( $dir ) {
$files = array_diff( scandir( $dir ), array( '.', '..' ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_scandir -- Test fixture cleanup.

foreach ( $files as $file ) {
$path = $dir . '/' . $file;

if ( is_dir( $path ) ) {
$this->remove_directory( $path );
} else {
unlink( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Test fixture cleanup.
}
}

rmdir( $dir ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- Test fixture cleanup.
}
}
Loading