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
125 changes: 125 additions & 0 deletions .github/workflows/deploy-zh-cn.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
name: Deploy zh-CN to OSS used by omarchy.cn

# omarchy.cn serves traffic from mainland China, so the zh-CN edition is
# deployed to Aliyun OSS in a mainland region. Every other locale, and the
# English GitHub Pages site, are untouched by this workflow -- see
# .github/workflows/pages.yml for those.
#
# Object storage rather than an edge Pages product: Aliyun's caps a project at
# 2000 static files, and a zh-CN build is around 5500 -- roughly 3300 of them
# the generated plugins/ redirect pages -- so no useful split fits under it.
# ref: https://help.aliyun.com/zh/edge-security-acceleration/esa/user-guide/what-is-functions-and-pages/

on:
push:
branches: [master, test/deploy-zh-cn-wrapper] # TEMPORARY
pull_request:
workflow_dispatch:

permissions:
contents: read

concurrency:
group: deploy-zh-cn-${{ github.ref }}
cancel-in-progress: false

env:
# Pinned so a deployment is reproducible. Not named RCLONE_*: rclone maps
# every RCLONE_<FLAG> environment variable onto the matching flag, so
# RCLONE_VERSION is read as --version and aborts the run.
# ref: https://rclone.org/docs/#environment-variables
OMARCHY_RCLONE_VERSION: 1.75.1
# SHA-256 of rclone-v1.75.1-linux-amd64.zip. Recorded here rather than fetched
# alongside the archive: a checksum served by the same host as the download
# would be replaced by anyone able to replace the download. Update both lines
# together when moving version, taking the digest from a trusted machine.
OMARCHY_RCLONE_SHA256: 982b5aa772841168f8e380f139e9e787b2a105403e32b94da8676a0e1c0a13ab

jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
- uses: actions/setup-python@v7
with:
python-version: '3.13'
# No lint, typecheck or tests here: pages.yml runs them on the same
# events, and its `npm test` already discovers scripts/test_*.py, so
# repeating them would only double the cost of every push and pull
# request. This job does the zh-CN-specific work and nothing else.
- run: npm ci
- uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1
with:
ruby-version: '3.4'
- run: gem install kramdown kramdown-parser-gfm --no-document
- run: bin/build-news
- run: npm run build:locale -- zh-CN
- run: python3 scripts/verify-locales.py zh-CN
# Required files, matching CNAME, no symlinks -- refuses a build that is
# not deployable, without contacting the bucket or needing credentials.
- run: npm run deploy:zh-cn -- --check
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
if: github.event_name != 'pull_request'
with:
name: omarchy-zh-cn
path: dist/zh-CN
if-no-files-found: error
retention-days: 14
include-hidden-files: true

deploy:
# Deploying is enabled by configuring where to deploy: a repository
# without OMARCHY_CN_OSS_HOST skips this job rather than failing, so the
# workflow stays inert until one opts in with that variable and the secrets.
if: >-
vars.OMARCHY_CN_OSS_HOST != '' &&
github.event_name == 'push' &&
(github.ref == 'refs/heads/master' || github.ref == 'refs/heads/test/deploy-zh-cn-wrapper')
needs: build
runs-on: ubuntu-latest
timeout-minutes: 20
environment:
name: omarchy-cn
# This job holds the deploy credentials, so its actions are pinned to
# commit SHAs: a retargeted tag could otherwise introduce code that runs on
# the same runner before the credential step and read it. The build job has
# no credentials and follows the repository's existing tag style.
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
with:
python-version: '3.13'
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: omarchy-zh-cn
path: dist/zh-CN

- name: Install rclone
run: |
set -euo pipefail
archive="rclone-v${OMARCHY_RCLONE_VERSION}-linux-amd64"
curl -fsSL -o /tmp/rclone.zip \
"https://downloads.rclone.org/v${OMARCHY_RCLONE_VERSION}/${archive}.zip"
# Verified before anything is unpacked, so a substituted archive never
# gets executed on the runner that holds the deploy credentials.
echo "${OMARCHY_RCLONE_SHA256} /tmp/rclone.zip" | sha256sum -c -
unzip -q /tmp/rclone.zip -d /tmp
install -m 0755 "/tmp/${archive}/rclone" /usr/local/bin/rclone
rclone version

# Called directly rather than through npm: this job sets up no Node.
- name: Deploy to OSS
env:
RCLONE_CONFIG_OSS_ACCESS_KEY_ID: ${{ secrets.OMARCHY_CN_ACCESS_KEY }}
RCLONE_CONFIG_OSS_SECRET_ACCESS_KEY: ${{ secrets.OMARCHY_CN_SECRET_KEY }}
OMARCHY_CN_OSS_HOST: ${{ vars.OMARCHY_CN_OSS_HOST }}
run: python3 scripts/deploy-zh-cn.py --deploy "$OMARCHY_CN_OSS_HOST"
8 changes: 7 additions & 1 deletion air/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,13 @@ <h3 class="resident__name"><a href="https://github.com/tahayvr">Taha</a></h3>
<p>He drew the Omarchy logo, which is reason enough, and he built <a href="https://github.com/tahayvr/omarchist">Omarchist</a>, the GUI for making your own themes without touching a config file. <a href="/manual/themes/">Matte Black</a> is his and ships with Omarchy, along with Gold Rush, Sunset Drive, and VHS 80 on <a href="/themes/">the extras page</a>.</p>
</article>

<p class="air__note">Remaining seats by invitation</p>
<article class="resident">
<img class="resident__avatar" src="/assets/images/air/yamz8.webp" width="240" height="240" alt="yamz8" loading="lazy" decoding="async">
<h3 class="resident__name"><a href="https://x.com/yamzeight">yamz8</a></h3>
<p>He makes Omarchy move. <a href="https://github.com/yamz8/themotion">Themotion</a> splits a theme&rsquo;s wallpaper into layers and animates them into a boot intro that fades in from black and lands exactly on the picture &mdash; Totoro rising into the moon, an eye opening, a wave swelling from a line. And <a href="https://yamz8.github.io/omarchy-themethrough/">Themethrough</a> is a short film that lays the Omarchy wordmark on the floor, fills it with the wallpapers from every theme, and flies the camera through it in five long takes.</p>
</article>

<p class="air__note">Remaining seat by invitation</p>
</section>

<section class="air__section">
Expand Down
Binary file added assets/images/air/yamz8.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"news:pending": "node scripts/check-translations.mjs --pending-news",
"news:translate": "python3 scripts/translate-news.py",
"build:translations": "npm run port && npm run check:translations && node scripts/build-translations.mjs",
"deploy:zh-cn": "python3 scripts/deploy-zh-cn.py",
"deploy:locale": "node scripts/deploy-locale.mjs"
},
"dependencies": {
Expand Down
1 change: 1 addition & 0 deletions patrons/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,7 @@ <h3 class="patrons__tier" id="patrons-16b">16B</h3>
<li>Vincent Ritter</li>
<li>Josh Waggoner</li>
<li>Daniel Heitmann</li>
<li>Adam Tohovitis</li>
<li>Ben Conger</li>
<li>Benjamin Calvert</li>
<li>Checed Rodgers</li>
Expand Down
2 changes: 1 addition & 1 deletion public/data/explorer.json

Large diffs are not rendered by default.

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
136 changes: 136 additions & 0 deletions scripts/deploy-zh-cn.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""Deploy the zh-CN build to an Aliyun OSS bucket.

omarchy.cn serves traffic from mainland China, so its build is deployed to an
Aliyun OSS bucket in a mainland region. Every other locale keeps using
deploy:locale.

--check validate dist/zh-CN (no network, no credentials)
--deploy HOST check, then upload dist/zh-CN with rclone and delete what the
build dropped

HOST is the bucket domain, <bucket>.oss-<region>.aliyuncs.com. rclone reads the
credentials from RCLONE_CONFIG_OSS_ACCESS_KEY_ID and
RCLONE_CONFIG_OSS_SECRET_ACCESS_KEY; this script never handles them.
ref: https://rclone.org/s3/
"""
import argparse
import json
import os
import re
import subprocess
import sys
from pathlib import Path
from urllib.parse import urlparse

LOCALE = 'zh-CN'
ROOT = Path(__file__).resolve().parent.parent
REQUIRED = ('index.html', '404.html', 'news/index.html', 'news/rss.xml', 'themes/index.html')
HOST = re.compile(r'^([a-z0-9][a-z0-9-]{1,61}[a-z0-9])\.(oss-[a-z0-9-]+\.aliyuncs\.com)$')

# Three copies then one sync, because --header-upload takes one value per
# invocation. Assets go up before HTML, so a page never references an asset
# that is not there yet. The sync carries no filter and no header: it copies
# nothing, since the copies already did, and exists to delete what the build
# dropped. It must stay unfiltered for that to be complete -- rclone ignores
# excluded files on both sides, so a filtered sync would leave stale objects
# outside its filter in place.
# ref: https://rclone.org/filtering/
PASSES = (
('copy', ('--include', '/_astro/**'), 'public, max-age=31536000, immutable'),
('copy', ('--exclude', '/_astro/**', '--exclude', '*.html'), 'public, max-age=300'),
('copy', ('--include', '*.html'), 'no-cache'),
('sync', (), None),
)

# --checksum compares the MD5 rclone reads from the bucket listing, so an
# unchanged file is skipped without a request of its own; every object is
# written whole, so its ETag is that MD5. --size-only would be cheaper still but
# cannot see an edit that leaves a file the same length. Stats are logged at
# INFO, below rclone's default NOTICE, so without --stats-log-level the log
# records nothing about what was copied.
# ref: https://rclone.org/docs/#c-checksum
COMMON = ('--checksum', '--fast-list', '--transfers', '32', '--checkers', '32',
'--stats-log-level', 'NOTICE')


def expected_domain():
"""The locale's domain from the registry, rather than a hardcoded host.

The build writes CNAME from the same source, so this check stays honest
when the registry changes and needs no edit alongside it.
"""
registry = json.loads((ROOT / 'src' / 'i18n' / 'locales.json').read_text())
return urlparse(registry[LOCALE]['domain']).hostname


def check(directory):
domain = expected_domain()
cname = (directory / 'CNAME').read_text().strip()
if cname != domain:
raise ValueError(f'Build CNAME is {cname}, expected {domain}')
for key in REQUIRED:
if not (directory / key).is_file():
raise ValueError(f'Missing required build file: {key}')
files = [path for path in directory.rglob('*') if not path.is_dir()]
# rclone skips symlinks unless told to follow them, and the deploy still
# succeeds, so a linked page would go missing without anything failing.
for path in files:
if path.is_symlink():
raise ValueError(f'Build contains a symlink: {path}')
total = sum(path.stat().st_size for path in files)
print(f'{len(files)} files, {total} bytes, CNAME {cname}: deployable', flush=True)


def parse_host(host):
"""Split the bucket domain into the bucket and the endpoint rclone needs."""
match = HOST.match(host or '')
if not match:
raise ValueError(f'{host!r} is not a bucket domain, <bucket>.oss-<region>.aliyuncs.com')
return match.group(1), match.group(2)


def commands(directory, bucket):
for verb, selection, cache_control in PASSES:
argv = ['rclone', verb, str(directory), f'oss:{bucket}', *COMMON, *selection]
if cache_control:
argv += ['--header-upload', f'Cache-Control: {cache_control}']
yield argv


def deploy(directory, host, run=subprocess.run):
check(directory)
bucket, endpoint = parse_host(host)
# The remote is defined entirely in the environment, so no config file is
# written and no credential appears in argv.
# ref: https://rclone.org/docs/#config-file
env = dict(os.environ,
RCLONE_CONFIG_OSS_TYPE='s3',
RCLONE_CONFIG_OSS_PROVIDER='Alibaba',
RCLONE_CONFIG_OSS_ENDPOINT=endpoint)
print(f'deploying {directory} to oss:{bucket} via {endpoint}', flush=True)
for argv in commands(directory, bucket):
print('+', ' '.join(argv), flush=True)
if run(argv, env=env).returncode != 0:
raise RuntimeError(f'rclone {argv[1]} failed')


def main():
parser = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument('--check', action='store_true', help='validate the build')
group.add_argument('--deploy', metavar='HOST', help='check, then deploy to the bucket at HOST')
args = parser.parse_args()
directory = Path('dist') / LOCALE
if args.deploy:
deploy(directory, args.deploy)
else:
check(directory)


if __name__ == '__main__':
try:
main()
except Exception as error:
sys.exit(f'zh-CN deployment failed: {error}')
Loading
Loading