Skip to content

Commit 671401f

Browse files
committed
Initial commit
0 parents  commit 671401f

23 files changed

Lines changed: 2464 additions & 0 deletions

.distignore

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
.git
2+
.github
3+
.gitattributes
4+
.gitignore
5+
.distignore
6+
.DS_Store
7+
__MACOSX
8+
._*
9+
bin
10+
dist
11+
docs
12+
tests
13+
phpcs.xml.dist
14+
phpcs.xml
15+
phpstan.neon
16+
phpstan.neon.dist
17+
composer.json
18+
composer.lock
19+
vendor
20+
node_modules
21+
package.json
22+
package-lock.json
23+
*.zip

.gitattributes

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/.github export-ignore
2+
/.gitattributes export-ignore
3+
/.gitignore export-ignore
4+
/.distignore export-ignore
5+
/bin export-ignore
6+
/docs export-ignore
7+
/tests export-ignore
8+
/phpcs.xml.dist export-ignore
9+
/composer.json export-ignore
10+
/composer.lock export-ignore

.github/workflows/ci.yml

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
tags: ['v*']
7+
pull_request:
8+
9+
jobs:
10+
lint:
11+
name: Lint (PHP ${{ matrix.php }})
12+
runs-on: ubuntu-latest
13+
strategy:
14+
fail-fast: false
15+
matrix:
16+
php: ['8.0', '8.2', '8.3']
17+
steps:
18+
- uses: actions/checkout@v5
19+
- uses: shivammathur/setup-php@v2
20+
with:
21+
php-version: ${{ matrix.php }}
22+
tools: composer
23+
coverage: none
24+
- name: Syntax check
25+
run: find . -name '*.php' -not -path './vendor/*' -print0 | xargs -0 -n1 -P4 php -l
26+
- name: Install PHPCS with WordPress standards
27+
if: matrix.php == '8.2'
28+
run: |
29+
composer global config --no-plugins allow-plugins.dealerdirect/phpcodesniffer-composer-installer true
30+
composer global require --no-interaction --no-progress wp-coding-standards/wpcs:^3.1 dealerdirect/phpcodesniffer-composer-installer:^1.0
31+
- name: PHPCS
32+
if: matrix.php == '8.2'
33+
run: |
34+
"$(composer global config bin-dir --absolute --quiet)/phpcs" --standard=phpcs.xml.dist
35+
36+
release:
37+
name: Release
38+
if: startsWith(github.ref, 'refs/tags/v')
39+
needs: lint
40+
runs-on: ubuntu-latest
41+
permissions:
42+
contents: write
43+
steps:
44+
- uses: actions/checkout@v5
45+
- name: Build archive
46+
run: bin/build.sh
47+
- name: Verify tag matches plugin version
48+
run: |
49+
SLUG="$(basename "$PWD")"
50+
VERSION="$(sed -nE 's/^[[:space:]]*\*[[:space:]]*Version:[[:space:]]*([0-9][^[:space:]]*).*/\1/p' "$SLUG.php" | head -n1)"
51+
TAG="${GITHUB_REF_NAME#v}"
52+
if [ "$VERSION" != "$TAG" ]; then
53+
echo "Plugin version $VERSION does not match tag $TAG" >&2
54+
exit 1
55+
fi
56+
- uses: softprops/action-gh-release@v3
57+
with:
58+
files: '*.zip'
59+
generate_release_notes: true

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/vendor/
2+
/node_modules/
3+
.DS_Store
4+
._*
5+
__MACOSX/
6+
*.zip

LICENSE

Lines changed: 338 additions & 0 deletions
Large diffs are not rendered by default.

README.md

Lines changed: 187 additions & 0 deletions
Large diffs are not rendered by default.

bin/build.sh

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
5+
SLUG="$(basename "$ROOT")"
6+
MAIN="$ROOT/$SLUG.php"
7+
VERSION="$(sed -nE 's/^[[:space:]]*\*[[:space:]]*Version:[[:space:]]*([0-9][^[:space:]]*).*/\1/p' "$MAIN" | head -n1)"
8+
DIST="$ROOT"
9+
STAGE="$(mktemp -d)"
10+
trap 'rm -rf "$STAGE"' EXIT
11+
12+
if [[ -z "$VERSION" ]]; then
13+
echo "Version header not found in $MAIN" >&2
14+
exit 1
15+
fi
16+
17+
find "$ROOT" -maxdepth 2 \( -name '.DS_Store' -o -name '__MACOSX' -o -name '._*' \) -prune -exec rm -rf {} + 2>/dev/null || true
18+
19+
mkdir -p "$DIST"
20+
rm -f "$DIST/$SLUG.zip"
21+
22+
rsync -a --exclude-from="$ROOT/.distignore" --exclude="*.zip" --exclude=".*" "$ROOT/" "$STAGE/$SLUG/"
23+
find "$STAGE" \( -name '.DS_Store' -o -name '._*' -o -name '__MACOSX' \) -prune -exec rm -rf {} + 2>/dev/null || true
24+
25+
(
26+
cd "$STAGE"
27+
COPYFILE_DISABLE=1 zip -X -r -q "$DIST/$SLUG.zip" "$SLUG"
28+
)
29+
30+
if unzip -l "$DIST/$SLUG.zip" | awk 'NR>3 {print $4}' | grep -Eq '(^|/)(\.|__MACOSX)'; then
31+
echo "Hidden or metadata files found inside the archive:" >&2
32+
unzip -l "$DIST/$SLUG.zip" | awk 'NR>3 {print $4}' | grep -E '(^|/)(\.|__MACOSX)' >&2
33+
exit 1
34+
fi
35+
36+
echo "Built $DIST/$SLUG.zip (version $VERSION)"
37+
unzip -l "$DIST/$SLUG.zip"

delayed-updates.php

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<?php
2+
/**
3+
* Plugin Name: Delayed Updates
4+
* Plugin URI: https://github.com/Poseidonas/delayed-updates
5+
* Description: Holds automatic updates back until a version has been out for a set number of days, so releases have time to prove themselves. Manual updates are untouched.
6+
* Version: 1.0.0
7+
* Requires at least: 6.7
8+
* Requires PHP: 8.0
9+
* Author: George Vasiliades
10+
* Author URI: https://github.com/Poseidonas
11+
* License: GPL-2.0-or-later
12+
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
13+
* Text Domain: delayed-updates
14+
* Domain Path: /languages
15+
* Update URI: https://github.com/Poseidonas/delayed-updates
16+
*/
17+
18+
declare(strict_types=1);
19+
20+
namespace ICXCNIKA\DelayedUpdates;
21+
22+
if ( ! defined( 'ABSPATH' ) ) {
23+
exit;
24+
}
25+
26+
const FILE = __FILE__;
27+
const SLUG = 'delayed-updates';
28+
const OPTION = 'icxcnika_delayed_settings';
29+
const SEEN = 'icxcnika_delayed_seen';
30+
const REPO = 'Poseidonas/delayed-updates';
31+
32+
spl_autoload_register(
33+
static function ( string $class_name ): void {
34+
$prefix = __NAMESPACE__ . '\\';
35+
if ( ! str_starts_with( $class_name, $prefix ) ) {
36+
return;
37+
}
38+
$name = strtolower( str_replace( '_', '-', substr( $class_name, strlen( $prefix ) ) ) );
39+
$file = __DIR__ . '/includes/class-' . $name . '.php';
40+
if ( is_file( $file ) ) {
41+
require $file;
42+
}
43+
}
44+
);
45+
46+
register_activation_hook( FILE, array( Plugin::class, 'activate' ) );
47+
register_deactivation_hook( FILE, array( Plugin::class, 'deactivate' ) );
48+
49+
add_action( 'plugins_loaded', array( Plugin::class, 'boot' ) );

includes/class-delay.php

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace ICXCNIKA\DelayedUpdates;
5+
6+
final class Delay {
7+
8+
public function __construct( private array $settings ) {}
9+
10+
public function register(): void {
11+
if ( ! $this->any_delay() ) {
12+
return;
13+
}
14+
add_filter( 'auto_update_plugin', array( $this, 'filter_plugin' ), 100, 2 );
15+
add_filter( 'auto_update_theme', array( $this, 'filter_theme' ), 100, 2 );
16+
add_filter( 'auto_update_core', array( $this, 'filter_core' ), 100, 2 );
17+
}
18+
19+
public function filter_plugin( $update, $item ) {
20+
return $this->decide( $update, 'plugin', self::describe_plugin( $item ) );
21+
}
22+
23+
public function filter_theme( $update, $item ) {
24+
return $this->decide( $update, 'theme', self::describe_theme( $item ) );
25+
}
26+
27+
public function filter_core( $update, $item ) {
28+
return $this->decide( $update, 'core', self::describe_core( $item ) );
29+
}
30+
31+
private function decide( $update, string $type, ?array $subject ) {
32+
if ( true !== $update || null === $subject ) {
33+
return $update;
34+
}
35+
36+
$days = $this->days_for( $type, $subject['version'] );
37+
if ( $days <= 0 ) {
38+
return $update;
39+
}
40+
41+
if ( 'plugin' === $type && in_array( $subject['id'], $this->settings['immediate'], true ) ) {
42+
return $update;
43+
}
44+
45+
if ( $this->settings['security_bypass']
46+
&& Release::security_notice( $type, $subject['slug'], $subject['version'], $subject['package'], $subject['notice'] )
47+
) {
48+
return $update;
49+
}
50+
51+
$held = self::held_until( $type, $subject, $days );
52+
if ( null === $held || $held <= time() ) {
53+
return $update;
54+
}
55+
56+
return false;
57+
}
58+
59+
public static function held_until( string $type, array $subject, int $days ): ?int {
60+
if ( $days <= 0 ) {
61+
return null;
62+
}
63+
$released = Release::date( $type, $subject['slug'], $subject['version'], $subject['package'] );
64+
if ( null === $released ) {
65+
$released = Store::first_seen( $type, $subject['id'], $subject['version'] );
66+
}
67+
return $released + ( $days * DAY_IN_SECONDS );
68+
}
69+
70+
public function days_for( string $type, string $version ): int {
71+
if ( 'plugin' === $type ) {
72+
return (int) $this->settings['delay_plugin'];
73+
}
74+
if ( 'theme' === $type ) {
75+
return (int) $this->settings['delay_theme'];
76+
}
77+
return self::is_major_core( $version )
78+
? (int) $this->settings['delay_core_major']
79+
: (int) $this->settings['delay_core_minor'];
80+
}
81+
82+
public static function is_major_core( string $version ): bool {
83+
$current = function_exists( 'wp_get_wp_version' ) ? wp_get_wp_version() : (string) get_bloginfo( 'version' );
84+
return self::branch( $current ) !== self::branch( $version );
85+
}
86+
87+
private static function branch( string $version ): string {
88+
$parts = explode( '.', preg_replace( '/[^0-9.].*$/', '', $version ) ?? $version );
89+
return ( $parts[0] ?? '0' ) . '.' . ( $parts[1] ?? '0' );
90+
}
91+
92+
public static function describe_plugin( $item ): ?array {
93+
$item = is_array( $item ) ? (object) $item : $item;
94+
if ( ! is_object( $item ) || empty( $item->plugin ) || empty( $item->new_version ) ) {
95+
return null;
96+
}
97+
return array(
98+
'id' => (string) $item->plugin,
99+
'slug' => (string) ( $item->slug ?? dirname( (string) $item->plugin ) ),
100+
'version' => (string) $item->new_version,
101+
'package' => (string) ( $item->package ?? '' ),
102+
'notice' => (string) ( $item->upgrade_notice ?? '' ),
103+
);
104+
}
105+
106+
public static function describe_theme( $item ): ?array {
107+
$item = is_array( $item ) ? (object) $item : $item;
108+
if ( ! is_object( $item ) || empty( $item->theme ) || empty( $item->new_version ) ) {
109+
return null;
110+
}
111+
return array(
112+
'id' => (string) $item->theme,
113+
'slug' => (string) $item->theme,
114+
'version' => (string) $item->new_version,
115+
'package' => (string) ( $item->package ?? '' ),
116+
'notice' => '',
117+
);
118+
}
119+
120+
public static function describe_core( $item ): ?array {
121+
if ( ! is_object( $item ) || empty( $item->current ) ) {
122+
return null;
123+
}
124+
return array(
125+
'id' => 'core',
126+
'slug' => '',
127+
'version' => (string) $item->current,
128+
'package' => '',
129+
'notice' => '',
130+
);
131+
}
132+
133+
private function any_delay(): bool {
134+
return $this->settings['delay_plugin'] > 0
135+
|| $this->settings['delay_theme'] > 0
136+
|| $this->settings['delay_core_minor'] > 0
137+
|| $this->settings['delay_core_major'] > 0;
138+
}
139+
}

includes/class-digest.php

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace ICXCNIKA\DelayedUpdates;
5+
6+
final class Digest {
7+
8+
public const HOOK = 'icxcnika_delayed_digest';
9+
10+
public static function register(): void {
11+
add_action( self::HOOK, array( self::class, 'send' ) );
12+
add_action( 'init', array( self::class, 'reconcile' ) );
13+
}
14+
15+
public static function reconcile(): void {
16+
self::schedule( (bool) Plugin::settings()['digest'] );
17+
}
18+
19+
public static function schedule( bool $wanted ): void {
20+
$scheduled = (bool) wp_next_scheduled( self::HOOK );
21+
if ( $wanted && ! $scheduled ) {
22+
wp_schedule_event( time() + DAY_IN_SECONDS, 'weekly', self::HOOK );
23+
} elseif ( ! $wanted && $scheduled ) {
24+
self::unschedule();
25+
}
26+
}
27+
28+
public static function unschedule(): void {
29+
wp_clear_scheduled_hook( self::HOOK );
30+
}
31+
32+
public static function send(): void {
33+
$held = Held::all();
34+
if ( array() === $held ) {
35+
return;
36+
}
37+
38+
$lines = array();
39+
foreach ( $held as $entry ) {
40+
$lines[] = sprintf(
41+
/* translators: 1: item name, 2: installed version, 3: new version, 4: date. */
42+
__( '%1$s: %2$s to %3$s, held until %4$s', 'delayed-updates' ),
43+
$entry['name'],
44+
'' !== $entry['from'] ? $entry['from'] : '?',
45+
$entry['to'],
46+
wp_date( get_option( 'date_format' ), $entry['due'] )
47+
);
48+
}
49+
50+
$body = implode( "\n", $lines ) . "\n\n" . admin_url( 'options-general.php?page=' . SLUG );
51+
52+
wp_mail(
53+
(string) apply_filters( 'icxcnika_delayed_digest_recipient', get_option( 'admin_email' ) ),
54+
sprintf(
55+
/* translators: %s: site name. */
56+
__( '[%s] Automatic updates on hold', 'delayed-updates' ),
57+
wp_specialchars_decode( (string) get_option( 'blogname' ), ENT_QUOTES )
58+
),
59+
$body
60+
);
61+
}
62+
}

0 commit comments

Comments
 (0)