
Github Pages Publisher
- 16 installs
- 177 repo stars
- Updated May 10, 2026
- artwist-polyakov/polyakov-claude-skills
github-pages-publisher is a Claude skill that publishes prebuilt static page artifacts to a GitHub Pages repository under a dated slug layout and returns the public URL.
About
This skill publishes an already-built static page artifact to a GitHub Pages repository using a fine-grained token and returns the final public URL. A developer uses it as the deployment layer after another skill has produced a static page. It enforces a year/year-month/page-slug directory layout, optionally optimizes oversized images, runs pre-publish validation, then commits and pushes to the Pages repo.
- Publishes prebuilt static artifacts to a GitHub Pages repo and returns the public URL
- Enforces a strict year/year-month/page-slug directory layout
- Optimizes oversized raster images and validates the artifact before pushing
Github Pages Publisher by the numbers
- 16 all-time installs (skills.sh)
- Ranked #943 of 1,435 DevOps & CI/CD skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
github-pages-publisher capabilities & compatibility
Requires a fine-grained GitHub token; GitHub Pages hosting itself is free
- Works with
- github
- Use cases
- ci cd · devops
- Pricing
- Free
What github-pages-publisher says it does
This skill is the **deployment/output layer** for page artifacts created by other skills.
Every published artifact must go into this path shape inside the target repo:
npx skills add https://github.com/artwist-polyakov/polyakov-claude-skills --skill github-pages-publisherAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 16 |
|---|---|
| repo stars | ★ 177 |
| Last updated | May 10, 2026 |
| Repository | artwist-polyakov/polyakov-claude-skills ↗ |
What it does
Publish a prebuilt static page artifact to a GitHub Pages repo under a dated slug layout and return its public URL.
Who is it for?
Developers deploying already-built static page artifacts to GitHub Pages
Skip if: Doing major frontend design work; it packages and publishes what already exists
When should I use this skill?
A React or static page artifact is prepared and needs to be published to a GitHub Pages repo with a public URL returned
What you get
A committed and pushed static artifact live on GitHub Pages with its final public directory URL returned.
- published GitHub Pages artifact
- final public URL
By the numbers
- default image target 500KB per raster image
- year/year-month/page-slug 3-level path layout
Files
GitHub Pages Publisher
Publish already-built static artifacts to a GitHub Pages repository.
This skill is the deployment/output layer for page artifacts created by other skills.
Required repository layout
Every published artifact must go into this path shape inside the target repo:
<year>/<year>-<month>/<page-slug>/
Example:
2026/2026-03/my-landing-page/
Never publish flat at repo root. Never skip the year or year-month nesting.
What this skill expects
Input should already exist as one of these:
- a folder of built static files
- a single HTML artifact plus local assets
- a small static site ready to serve from a subdirectory
This skill should not do major frontend design work. It should package and publish what already exists.
Default publishing rules
- preserve relative asset paths when possible
- prefer
index.htmlas entrypoint inside the target page directory - keep the output self-contained inside the page folder
- avoid breaking existing published pages
- if the same slug is republished, update the existing folder contents deliberately
URL contract
Always return the final public URL of the artifact.
Assume GitHub Pages serves from the repo's configured Pages base URL. The final URL should be:
<pages-base-url>/<year>/<year>-<month>/<page-slug>/
or, if needed explicitly:
.../<page-slug>/index.html
Prefer the clean directory URL when it resolves correctly.
Workflow
1. Determine the publish date bucket:
- year =
YYYY - year-month =
YYYY-MM
2. Create or update target directory:
<year>/<year-month>/<page-slug>/
3. Copy artifact files into that directory 4. Optimize oversized raster images when practical, unless original-size sharing is intentional 5. Verify there is an entrypoint (index.html normally) 6. Run pre-publish validation (see section above) 7. Commit and push to the Pages repo using the configured fine-grained token workflow 8. Return the final public URL
Publishing command
python3 scripts/publish_static.py --source <dir-or-html> --slug <name> [--date YYYY-MM-DD] [--image-max-kb 500]The script copies the artifact into YYYY/YYYY-MM/slug/, optimizes oversized .jpg, .jpeg, .png, and .webp images, commits, pushes, and prints the final public URL.
Image optimization
Image optimization is a recommendation, not a hard requirement. By default, the publishing script tries to keep each raster image under 500KB by stripping metadata, recompressing, and resizing long edges when needed.
Use --image-max-kb <kb> for a different target. Use --keep-large-images or --image-max-kb 0 when the user explicitly needs original-size images (for example, a full-resolution infographic, map, or downloadable media asset).
The original --source artifact is not changed. Optimization happens only after files are copied into the publish repo target directory. If Pillow/uv is unavailable or optimization fails, continue publishing originals and mention the warning.
Manual console run for a prepared artifact:
uv run --with pillow python3 scripts/optimize_images.py <artifact-dir> --max-kb 500Slug rules
- use lowercase letters, digits, and hyphens
- keep it short and descriptive
- avoid spaces, underscores, Cyrillic, timestamps unless needed for uniqueness
- if the title is user-facing, the slug can still be normalized separately
Pre-publish validation
Before pushing, the agent must verify:
- artifact has
index.htmlentrypoint - oversized raster images are optimized when practical, unless original-size sharing is intentional
- no absolute local paths in HTML/CSS
- no secrets in files
- all local assets reachable from the target folder
- page renders correctly (if browser tools are available, check at desktop 1440px and mobile 375px)
Safety rules
- do not delete unrelated directories in the Pages repo
- only replace contents of the target page directory being published
- if overwriting, say so in the result
- do not expose the token in output, logs, or committed files
- do not push artifacts that fail local viewport validation
Config
Read config/README.md for required environment variables and URL derivation.
References
Read if needed:
references/publish-checklist.md— operational checklist before pushing
config/.env
GHPAGES_TOKEN=xxxxxxx
GHPAGES_REPO=YOURS_PROFILE/YOURS_REPO
GHPAGES_BRANCH=main
GHPAGES_PAGES_BASE_URL=https://YOURS_PROFILE.github.io/YOURS_REPO
Config
Required environment variables:
GHPAGES_TOKEN— fine-grained GitHub token with contents write access to the target repoGHPAGES_REPO—owner/repoGHPAGES_BRANCH— target branch (usuallymainorgh-pages)GHPAGES_PAGES_BASE_URL— public Pages base URL, for examplehttps://example.github.io/repo
The publisher writes artifacts into:
YYYY/YYYY-MM/page-slug/
The final URL is derived as:
<GHPAGES_PAGES_BASE_URL>/YYYY/YYYY-MM/page-slug/
Publish checklist
Before push:
- target path follows
YYYY/YYYY-MM/page-slug/ - artifact has
index.html - all local assets remain reachable from that folder
- oversized raster images are optimized when practical or intentionally kept large
- no accidental absolute local paths
- no secrets in files
- final URL is computed and returned
#!/usr/bin/env python3
"""Optimize oversized raster images in-place before publishing."""
from __future__ import annotations
import argparse
import shutil
import sys
from dataclasses import dataclass
from pathlib import Path
try:
from PIL import Image, ImageOps, UnidentifiedImageError
except ImportError as exc:
raise SystemExit(
'Pillow is required. Run with: '
'uv run --with pillow python3 scripts/optimize_images.py <path> --max-kb 500'
) from exc
IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.webp'}
DEFAULT_MAX_KB = 500
DEFAULT_MAX_DIMENSION = 1800
MIN_DIMENSION = 640
QUALITY_VALUES = range(85, 40, -5)
RESAMPLE = getattr(getattr(Image, 'Resampling', Image), 'LANCZOS')
@dataclass
class OptimizeResult:
status: str
original_size: int
final_size: int
reason: str = ''
def iter_images(root: Path):
paths = [root] if root.is_file() else root.rglob('*')
for path in sorted(paths):
if path.is_file() and path.suffix.lower() in IMAGE_EXTENSIONS:
yield path
def output_format(path: Path) -> str:
ext = path.suffix.lower()
if ext in {'.jpg', '.jpeg'}:
return 'JPEG'
if ext == '.png':
return 'PNG'
return 'WEBP'
def dimension_steps(width: int, height: int, max_dimension: int):
largest = max(width, height)
start = min(largest, max_dimension)
if start <= MIN_DIMENSION:
return [start]
steps = []
current = start
while current >= MIN_DIMENSION:
steps.append(current)
current = int(current * 0.85)
if steps[-1] != MIN_DIMENSION:
steps.append(MIN_DIMENSION)
return steps
def flatten_to_rgb(image: Image.Image) -> Image.Image:
if image.mode in {'RGBA', 'LA'} or (image.mode == 'P' and 'transparency' in image.info):
rgba = image.convert('RGBA')
background = Image.new('RGB', rgba.size, (255, 255, 255))
background.paste(rgba, mask=rgba.getchannel('A'))
return background
return image.convert('RGB')
def save_candidate(source: Image.Image, target: Path, fmt: str, max_dimension: int, quality: int | None):
image = source.copy()
if max(image.size) > max_dimension:
image.thumbnail((max_dimension, max_dimension), RESAMPLE)
if fmt == 'JPEG':
flatten_to_rgb(image).save(
target,
format='JPEG',
quality=quality,
optimize=True,
progressive=True,
)
elif fmt == 'WEBP':
image.save(target, format='WEBP', quality=quality, method=6)
else:
image.save(target, format='PNG', optimize=True, compress_level=9)
def optimize_one(path: Path, max_bytes: int, max_dimension: int) -> OptimizeResult:
original_size = path.stat().st_size
if original_size <= max_bytes:
return OptimizeResult('already_small', original_size, original_size)
tmp = path.with_name(f'.{path.name}.optimize-tmp{path.suffix}')
best = path.with_name(f'.{path.name}.optimize-best{path.suffix}')
try:
with Image.open(path) as opened:
opened.load()
if getattr(opened, 'is_animated', False):
return OptimizeResult('skipped', original_size, original_size, 'animated image')
fmt = output_format(path)
source = ImageOps.exif_transpose(opened)
qualities: list[int | None] = [None] if fmt == 'PNG' else list(QUALITY_VALUES)
best_size = original_size
for max_dim in dimension_steps(*source.size, max_dimension):
for quality in qualities:
save_candidate(source, tmp, fmt, max_dim, quality)
candidate_size = tmp.stat().st_size
if candidate_size < best_size:
shutil.copy2(tmp, best)
best_size = candidate_size
if candidate_size <= max_bytes:
tmp.replace(path)
if best.exists():
best.unlink()
return OptimizeResult('optimized', original_size, candidate_size)
if best.exists() and best_size < original_size:
best.replace(path)
status = 'optimized_over_target' if best_size > max_bytes else 'optimized'
return OptimizeResult(status, original_size, best_size)
return OptimizeResult('kept_original', original_size, original_size, 'no smaller candidate')
except UnidentifiedImageError:
return OptimizeResult('skipped', original_size, original_size, 'unidentified image')
except OSError as exc:
return OptimizeResult('skipped', original_size, original_size, str(exc))
finally:
if tmp.exists():
tmp.unlink()
if best.exists():
best.unlink()
def human_size(size: int) -> str:
return f'{size / 1024:.1f}KB'
def relative(path: Path, root: Path) -> str:
try:
return path.relative_to(root).as_posix()
except ValueError:
return path.as_posix()
def main():
parser = argparse.ArgumentParser(description='Optimize oversized web images in-place')
parser.add_argument('path', help='Image file or directory to scan')
parser.add_argument('--max-kb', type=int, default=DEFAULT_MAX_KB, help='Target max size per image')
parser.add_argument(
'--max-dimension',
type=int,
default=DEFAULT_MAX_DIMENSION,
help='Initial long-edge resize limit for oversized images',
)
args = parser.parse_args()
root = Path(args.path).resolve()
if not root.exists():
raise SystemExit(f'path not found: {root}')
if args.max_kb <= 0:
print('IMAGE_OPTIMIZATION_DISABLED')
return
max_bytes = args.max_kb * 1024
optimized = 0
over_target = 0
skipped = 0
for image_path in iter_images(root):
result = optimize_one(image_path, max_bytes, args.max_dimension)
rel = relative(image_path, root if root.is_dir() else root.parent)
if result.status == 'optimized':
optimized += 1
print(f'IMAGE_OPTIMIZED {rel} {human_size(result.original_size)} -> {human_size(result.final_size)}')
elif result.status == 'optimized_over_target':
optimized += 1
over_target += 1
print(
f'IMAGE_OPTIMIZED_OVER_TARGET {rel} '
f'{human_size(result.original_size)} -> {human_size(result.final_size)}',
file=sys.stderr,
)
elif result.status == 'skipped':
skipped += 1
print(f'IMAGE_OPTIMIZATION_SKIPPED {rel}: {result.reason}', file=sys.stderr)
if optimized or over_target or skipped:
print(f'IMAGE_OPTIMIZATION_DONE optimized={optimized} over_target={over_target} skipped={skipped}')
if __name__ == '__main__':
main()
#!/usr/bin/env python3
import argparse, os, re, shutil, subprocess, sys, tempfile
from datetime import datetime
from pathlib import Path
DEFAULT_IMAGE_MAX_KB = 500
def slugify(s: str) -> str:
s = s.strip().lower()
s = re.sub(r'[^a-z0-9]+', '-', s)
s = re.sub(r'-+', '-', s).strip('-')
return s or 'page'
def run(cmd, cwd=None):
subprocess.run(cmd, cwd=cwd, check=True)
def pillow_available() -> bool:
return subprocess.run(
[sys.executable, '-c', 'import PIL'],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
).returncode == 0
def optimize_images(target: Path, max_kb: int, keep_large_images: bool):
if keep_large_images:
print('IMAGE_OPTIMIZATION_SKIPPED keep-large-images')
return
if max_kb <= 0:
print('IMAGE_OPTIMIZATION_DISABLED')
return
script = Path(__file__).resolve().with_name('optimize_images.py')
if not script.exists():
print(f'IMAGE_OPTIMIZATION_SKIPPED optimizer not found: {script}', file=sys.stderr)
return
cmd = None
if pillow_available():
cmd = [sys.executable, str(script), str(target), '--max-kb', str(max_kb)]
else:
uv = shutil.which('uv')
if uv:
cmd = [uv, 'run', '--quiet', '--with', 'pillow', 'python3', str(script), str(target), '--max-kb', str(max_kb)]
if cmd:
status = subprocess.run(cmd)
if status.returncode != 0:
print('IMAGE_OPTIMIZATION_SKIPPED optimizer failed; publishing originals', file=sys.stderr)
return
print('IMAGE_OPTIMIZATION_SKIPPED Pillow unavailable; publishing originals', file=sys.stderr)
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--source', required=True, help='Path to built static site folder or single html file')
ap.add_argument('--slug', required=True)
ap.add_argument('--date', help='ISO date, default today')
ap.add_argument('--message', help='Commit message')
ap.add_argument('--image-max-kb', type=int, default=DEFAULT_IMAGE_MAX_KB, help='Target max size per raster image')
ap.add_argument('--keep-large-images', action='store_true', help='Publish original images without optimization')
args = ap.parse_args()
token = os.environ['GHPAGES_TOKEN']
repo = os.environ['GHPAGES_REPO']
branch = os.environ.get('GHPAGES_BRANCH', 'main')
base_url = os.environ['GHPAGES_PAGES_BASE_URL'].rstrip('/')
dt = datetime.fromisoformat(args.date) if args.date else datetime.utcnow()
year = dt.strftime('%Y')
year_month = dt.strftime('%Y-%m')
slug = slugify(args.slug)
rel = Path(year) / year_month / slug
src = Path(args.source).resolve()
if not src.exists():
raise SystemExit(f'source not found: {src}')
with tempfile.TemporaryDirectory() as td:
repo_dir = Path(td) / 'repo'
remote = f'https://x-access-token:{token}@github.com/{repo}.git'
run(['git', 'clone', '--branch', branch, '--depth', '1', remote, str(repo_dir)])
target = repo_dir / rel
if target.exists():
shutil.rmtree(target)
target.mkdir(parents=True, exist_ok=True)
if src.is_dir():
for item in src.iterdir():
dest = target / item.name
if item.is_dir():
shutil.copytree(item, dest)
else:
shutil.copy2(item, dest)
else:
shutil.copy2(src, target / 'index.html')
optimize_images(target, args.image_max_kb, args.keep_large_images)
if not (target / 'index.html').exists():
raise SystemExit('index.html not found in published artifact')
run(['git', 'config', 'user.name', 'OpenClaw Publisher'], cwd=repo_dir)
run(['git', 'config', 'user.email', 'publisher@openclaw.local'], cwd=repo_dir)
run(['git', 'add', str(rel)], cwd=repo_dir)
msg = args.message or f'publish: {slug} -> {rel.as_posix()}'
status = subprocess.run(['git', 'diff', '--cached', '--quiet'], cwd=repo_dir)
if status.returncode == 0:
print(f'NO_CHANGES {base_url}/{rel.as_posix()}/')
return
run(['git', 'commit', '-m', msg], cwd=repo_dir)
run(['git', 'push', 'origin', branch], cwd=repo_dir)
print(f'{base_url}/{rel.as_posix()}/')
if __name__ == '__main__':
main()
Related skills
FAQ
What directory layout does it enforce?
Every artifact goes into year/year-month/page-slug, never flat at the repo root and never skipping the nesting.
Does it do frontend design?
No. It is the deployment/output layer and packages and publishes an artifact that already exists.