summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--deploy.config.example.psd117
-rw-r--r--deploy.ps1314
-rw-r--r--doc/Design.md42
4 files changed, 371 insertions, 3 deletions
diff --git a/.gitignore b/.gitignore
index c9253a1..e2f46f9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,3 @@
/public/
/draft/
+/deploy.config.psd1
diff --git a/deploy.config.example.psd1 b/deploy.config.example.psd1
new file mode 100644
index 0000000..30e6a3e
--- /dev/null
+++ b/deploy.config.example.psd1
@@ -0,0 +1,17 @@
+@{
+ # SSH host name or SSH config alias.
+ DeployHost = "www.example.com"
+
+ # Remote Unix account used only for deployment.
+ DeployUser = "deploy"
+
+ # Existing document root base. Must be below /srv/www/.
+ RemoteBase = "/srv/www/example.com"
+
+ # Leave empty to use the standard OpenSSH key lookup and SSH Agent.
+ # A relative path is resolved from the repository root.
+ IdentityFile = ""
+
+ # Printed after a successful deployment.
+ SiteUrl = "https://www.example.com/"
+}
diff --git a/deploy.ps1 b/deploy.ps1
new file mode 100644
index 0000000..970bacd
--- /dev/null
+++ b/deploy.ps1
@@ -0,0 +1,314 @@
+Set-StrictMode -Version Latest
+$ErrorActionPreference = "Stop"
+
+$siteRoot = $PSScriptRoot
+$configPath = $null
+$localArchive = $null
+$remoteArchive = $null
+$remoteArchiveMayExist = $false
+$deploymentSucceeded = $false
+$failureMessage = $null
+$locationPushed = $false
+$nativeCommands = @{}
+$sshOptions = @("-o", "BatchMode=yes", "-o", "ConnectTimeout=10")
+
+function Get-NativeCommandPath {
+ param(
+ [Parameter(Mandatory)]
+ [string]$Name
+ )
+
+ $command = Get-Command -Name $Name -CommandType Application -ErrorAction Stop
+ return $command.Source
+}
+
+function Invoke-CheckedNativeCommand {
+ param(
+ [Parameter(Mandatory)]
+ [string]$Command,
+
+ [Parameter()]
+ [string[]]$Arguments = @(),
+
+ [Parameter(Mandatory)]
+ [string]$Operation
+ )
+
+ & $Command @Arguments
+ $exitCode = $LASTEXITCODE
+ if ($exitCode -ne 0) {
+ throw "$Operation failed with exit code $exitCode."
+ }
+}
+
+function Invoke-CheckedSshScript {
+ param(
+ [Parameter(Mandatory)]
+ [string]$Command,
+
+ [Parameter(Mandatory)]
+ [string[]]$Options,
+
+ [Parameter(Mandatory)]
+ [string]$Target,
+
+ [Parameter(Mandatory)]
+ [string]$Script
+ )
+
+ $Script | & $Command @Options $Target "sh -s"
+ $exitCode = $LASTEXITCODE
+ if ($exitCode -ne 0) {
+ throw "Remote release activation failed with exit code $exitCode."
+ }
+}
+
+try {
+ if ($args.Count -gt 0) {
+ throw "deploy.ps1 does not accept arguments. Edit deploy.config.psd1 instead."
+ }
+
+ if ([string]::IsNullOrWhiteSpace($siteRoot)) {
+ throw "Unable to determine the site root. Run deploy.ps1 from a saved file."
+ }
+
+ $siteRoot = (Resolve-Path -LiteralPath $siteRoot).Path
+ if (-not (Test-Path -LiteralPath (Join-Path $siteRoot "zola.toml") -PathType Leaf)) {
+ throw "zola.toml was not found next to deploy.ps1."
+ }
+
+ $configPath = Join-Path $siteRoot "deploy.config.psd1"
+ if (-not (Test-Path -LiteralPath $configPath -PathType Leaf)) {
+ throw (
+ "deploy.config.psd1 was not found. " +
+ "Copy deploy.config.example.psd1 and enter the local deployment settings."
+ )
+ }
+
+ $deployConfig = Import-PowerShellDataFile -LiteralPath $configPath
+ if ($deployConfig -isnot [System.Collections.IDictionary]) {
+ throw "deploy.config.psd1 must contain a PowerShell data hashtable."
+ }
+
+ $requiredConfigKeys = @(
+ "DeployHost",
+ "DeployUser",
+ "RemoteBase",
+ "IdentityFile",
+ "SiteUrl"
+ )
+ foreach ($key in $requiredConfigKeys) {
+ if (-not $deployConfig.Contains($key)) {
+ throw "deploy.config.psd1 is missing the required key: $key"
+ }
+ }
+
+ $DeployHost = ([string]$deployConfig["DeployHost"]).Trim()
+ $DeployUser = ([string]$deployConfig["DeployUser"]).Trim()
+ $RemoteBase = ([string]$deployConfig["RemoteBase"]).Trim()
+ $IdentityFile = ([string]$deployConfig["IdentityFile"]).Trim()
+ $siteUrl = ([string]$deployConfig["SiteUrl"]).Trim()
+
+ if ($DeployHost -notmatch '^[A-Za-z0-9._-]+$') {
+ throw "DeployHost contains unsupported characters."
+ }
+ if ($DeployUser -notmatch '^[A-Za-z_][A-Za-z0-9_-]*$') {
+ throw "DeployUser contains unsupported characters."
+ }
+
+ $RemoteBase = $RemoteBase.TrimEnd('/')
+ if (
+ $RemoteBase.Length -le 1 -or
+ $RemoteBase -notmatch '^/[A-Za-z0-9._/-]+$' -or
+ $RemoteBase -match '(^|/)\.\.?(/|$)' -or
+ $RemoteBase.Contains('//')
+ ) {
+ throw "RemoteBase must be a normalized absolute Unix path other than '/'."
+ }
+ if (-not $RemoteBase.StartsWith('/srv/www/', [System.StringComparison]::Ordinal)) {
+ throw "RemoteBase must be located below /srv/www/."
+ }
+
+ $parsedSiteUrl = $null
+ if (
+ -not [System.Uri]::TryCreate(
+ $siteUrl,
+ [System.UriKind]::Absolute,
+ [ref]$parsedSiteUrl
+ ) -or
+ $parsedSiteUrl.Scheme -ne "https"
+ ) {
+ throw "SiteUrl must be an absolute HTTPS URL."
+ }
+
+ if (-not [string]::IsNullOrWhiteSpace($IdentityFile)) {
+ $identityCandidate = $IdentityFile
+ if (-not [System.IO.Path]::IsPathRooted($identityCandidate)) {
+ $identityCandidate = Join-Path $siteRoot $identityCandidate
+ }
+ $identityPath = (Resolve-Path -LiteralPath $identityCandidate -ErrorAction Stop).Path
+ if (-not (Test-Path -LiteralPath $identityPath -PathType Leaf)) {
+ throw "IdentityFile must point to a file."
+ }
+ $sshOptions += @("-i", $identityPath)
+ }
+
+ foreach ($name in @("zola", "tar", "ssh", "scp")) {
+ $nativeCommands[$name] = Get-NativeCommandPath -Name $name
+ }
+
+ Push-Location -LiteralPath $siteRoot
+ $locationPushed = $true
+
+ Write-Host "[1/6] Checking site..."
+ Invoke-CheckedNativeCommand `
+ -Command $nativeCommands["zola"] `
+ -Arguments @("check") `
+ -Operation "zola check"
+
+ Write-Host "[2/6] Building site..."
+ Invoke-CheckedNativeCommand `
+ -Command $nativeCommands["zola"] `
+ -Arguments @("build") `
+ -Operation "zola build"
+
+ $publicDir = Join-Path $siteRoot "public"
+ $publicIndex = Join-Path $publicDir "index.html"
+ if (-not (Test-Path -LiteralPath $publicIndex -PathType Leaf)) {
+ throw "Build validation failed: public/index.html was not generated."
+ }
+
+ $resolvedPublicDir = (Resolve-Path -LiteralPath $publicDir).Path
+ $siteRootPrefix = $siteRoot.TrimEnd(
+ [System.IO.Path]::DirectorySeparatorChar,
+ [System.IO.Path]::AltDirectorySeparatorChar
+ ) + [System.IO.Path]::DirectorySeparatorChar
+ if (-not $resolvedPublicDir.StartsWith(
+ $siteRootPrefix,
+ [System.StringComparison]::OrdinalIgnoreCase
+ )) {
+ throw "Resolved public directory is outside the site root."
+ }
+
+ $releaseId = Get-Date -Format "yyyyMMdd-HHmmss"
+ $archiveNonce = [System.Guid]::NewGuid().ToString("N")
+ $archiveName = "takashi-2521-$releaseId-$archiveNonce.tar.gz"
+ $localArchive = Join-Path ([System.IO.Path]::GetTempPath()) $archiveName
+ $remoteArchive = "/tmp/$archiveName"
+ $remoteTarget = "$DeployUser@$DeployHost"
+
+ Write-Host "[3/6] Creating archive..."
+ Invoke-CheckedNativeCommand `
+ -Command $nativeCommands["tar"] `
+ -Arguments @("-czf", $localArchive, "-C", $resolvedPublicDir, ".") `
+ -Operation "tar archive creation"
+
+ if (
+ -not (Test-Path -LiteralPath $localArchive -PathType Leaf) -or
+ (Get-Item -LiteralPath $localArchive).Length -le 0
+ ) {
+ throw "Archive validation failed: the local archive is missing or empty."
+ }
+
+ Write-Host "[4/6] Uploading release..."
+ $scpArguments = @($sshOptions) + @(
+ $localArchive,
+ "${remoteTarget}:$remoteArchive"
+ )
+ $remoteArchiveMayExist = $true
+ Invoke-CheckedNativeCommand `
+ -Command $nativeCommands["scp"] `
+ -Arguments $scpArguments `
+ -Operation "SCP upload"
+
+ $remoteScript = @'
+set -eu
+umask 022
+
+base='__REMOTE_BASE__'
+release_id='__RELEASE_ID__'
+archive='__REMOTE_ARCHIVE__'
+release="$base/releases/$release_id"
+new_link="$base/current.new"
+release_created=0
+
+cleanup() {
+ status=$?
+ trap - 0
+ rm -f -- "$archive" || true
+ rm -f -- "$new_link" || true
+ if [ "$status" -ne 0 ] && [ "$release_created" -eq 1 ]; then
+ rm -rf -- "$release" || true
+ fi
+ exit "$status"
+}
+trap cleanup 0
+
+test -d "$base/releases"
+test -L "$base/current"
+test -f "$base/current/index.html"
+test -f "$archive"
+
+if [ -e "$release" ] || [ -L "$release" ]; then
+ echo "Release already exists: $release_id" >&2
+ exit 1
+fi
+
+if [ -e "$new_link" ] || [ -L "$new_link" ]; then
+ rm -f -- "$new_link"
+fi
+
+mkdir -- "$release"
+release_created=1
+tar -xzf "$archive" -C "$release"
+test -f "$release/index.html"
+
+ln -s "releases/$release_id" "$new_link"
+mv -Tf "$new_link" "$base/current"
+release_created=0
+'@
+ $remoteScript = $remoteScript.Replace("__REMOTE_BASE__", $RemoteBase)
+ $remoteScript = $remoteScript.Replace("__RELEASE_ID__", $releaseId)
+ $remoteScript = $remoteScript.Replace("__REMOTE_ARCHIVE__", $remoteArchive)
+
+ Write-Host "[5/6] Switching release..."
+ Invoke-CheckedSshScript `
+ -Command $nativeCommands["ssh"] `
+ -Options $sshOptions `
+ -Target $remoteTarget `
+ -Script $remoteScript
+
+ $deploymentSucceeded = $true
+}
+catch {
+ $failureMessage = $_.Exception.Message
+}
+finally {
+ if ($locationPushed) {
+ Pop-Location
+ }
+
+ if ($null -ne $localArchive -and (Test-Path -LiteralPath $localArchive)) {
+ Remove-Item -LiteralPath $localArchive -Force -ErrorAction SilentlyContinue
+ }
+
+ if (
+ $remoteArchiveMayExist -and
+ -not $deploymentSucceeded -and
+ $null -ne $remoteArchive
+ ) {
+ $cleanupCommand = "rm -f -- '$remoteArchive'"
+ & $nativeCommands["ssh"] @sshOptions $remoteTarget $cleanupCommand 2>$null | Out-Null
+ }
+}
+
+if (-not $deploymentSucceeded) {
+ [System.Console]::Error.WriteLine("Deployment failed: $failureMessage")
+ exit 1
+}
+
+Write-Host "[6/6] Done."
+Write-Host ""
+Write-Host "Release: $releaseId"
+Write-Host "URL: $siteUrl"
+exit 0
diff --git a/doc/Design.md b/doc/Design.md
index a588d39..0b2d443 100644
--- a/doc/Design.md
+++ b/doc/Design.md
@@ -28,18 +28,21 @@
```text
content/*.md ─┐
-templates/* ──┼─> Zola ─> public/ ─> 静的配信
+templates/* ──┼─> Zola ─> public/ ─> deploy.ps1 ─> VPS releases/ ─> current
sass/*.scss ──┤
static/* ─────┘
```
-ZolaがSection、Page、Taxonomyを解決し、TeraでHTMLを生成する。SCSSは`main.css`へコンパイルし、`static/`とPage Bundle内のassetを出力へコピーする。公開環境は`public/`の内容だけで成立する。
+ZolaがSection、Page、Taxonomyを解決し、TeraでHTMLを生成する。SCSSは`main.css`へコンパイルし、`static/`とPage Bundle内のassetを出力へコピーする。公開環境は`public/`の内容だけで成立し、VPSで再ビルドしない。
## 3. リポジトリ構造
```text
.
├── zola.toml
+├── deploy.ps1
+├── deploy.config.example.psd1
+├── deploy.config.psd1 # local only
├── content/
│ ├── _index.md
│ ├── about.md
@@ -83,6 +86,8 @@ ZolaがSection、Page、Taxonomyを解決し、TeraでHTMLを生成する。SCSS
| `themes/` | 外部themeを導入せず空で保持する。 |
| `public/` | ビルド生成物。Git管理対象外とする。 |
+`deploy.config.psd1`は環境固有値を含むためGit管理対象外とし、構造だけを示す`deploy.config.example.psd1`を管理する。
+
## 4. Zola設定
設定の完全な値と各項目の説明は`zola.toml`を正とする。本節では、サイト構造に影響する設定をまとめる。
@@ -467,6 +472,7 @@ Markdownの @/ 内部リンク
| 開発表示 | `zola serve` |
| 構造・link検証 | `zola check` |
| 生成 | `zola build` |
+| 本番deploy | `.\deploy.ps1` |
### 14.2 必須検証
@@ -482,10 +488,40 @@ Markdownの @/ 内部リンク
| Security | inline script、inline event handler、外部CDN参照がない。 |
| Responsive | Header、一覧、Software metadata、Footerが各breakpointで崩れない。 |
| Accessibility | skip link、focus、現在地、見出し順、theme buttonを確認する。 |
+| Deploy script | PowerShell構文、設定欠落・不正値のnon-zero終了、archive直下の`index.html`を確認する。 |
### 14.3 配信境界
-本リポジトリの責務は`public/`の生成までとする。VPS、Caddy、TLS、cache、CSP header、deploy scriptは外部の配信環境で管理し、本設計の対象外とする。
+サイト生成処理の責務は`public/`までとする。`deploy.ps1`は生成物を既設VPSへ転送し、既存のrelease構造へ配置する外部配信操作を自動化するが、VPS上でZolaやGitを実行しない。
+
+| 項目 | 設計 |
+|---|---|
+| 設定読込 | rootの`deploy.config.psd1`を`Import-PowerShellDataFile`で自動読込する。command line引数は使用しない。 |
+| 接続 | 設定内の`DeployHost`と`DeployUser`をWindows OpenSSHの`ssh`/`scp`へ渡す。 |
+| 認証 | `IdentityFile`が空ならOpenSSH標準key探索を使用し、値があれば指定fileを使用する。 |
+| Remote base | 設定内の`RemoteBase`を使用し、誤操作防止のため`/srv/www/`配下だけを許可する。 |
+| 転送単位 | `public/`の中身だけを含む一時`tar.gz`。 |
+| Release ID | ローカル時刻の`yyyyMMdd-HHmmss`。 |
+| Remote release | `<RemoteBase>/releases/<release-id>/` |
+| 公開切替 | `current.new`を作り、`mv -Tf current.new current`でatomicに置換する。 |
+| Cleanup | local/remote archive、失敗時の`current.new`と新規作成releaseを削除する。 |
+| 保持 | 過去releaseを削除せず、手動rollbackに利用できる状態を保つ。 |
+
+設定fileのschemaは次のとおりである。
+
+| Key | 必須 | 内容 |
+|---|---:|---|
+| `DeployHost` | Yes | SSH host名またはSSH config alias |
+| `DeployUser` | Yes | deploy専用Unix user |
+| `RemoteBase` | Yes | `/srv/www/`配下の公開base |
+| `IdentityFile` | Yes | 空文字列またはSSH identity file path |
+| `SiteUrl` | Yes | 成功時に表示するHTTPS URL |
+
+初回だけ`deploy.config.example.psd1`を`deploy.config.psd1`へコピーして実値を設定する。以後は`.\deploy.ps1`だけで実行する。実設定は`.gitignore`によりcommitしない。
+
+切替前にlocalとremote双方の`index.html`を確認する。既存releaseが同名の場合は失敗させ、上書きしない。`zola check`、`zola build`、archive作成、転送、展開のいずれかが失敗した場合はnon-zeroで終了する。
+
+VPS、Caddy、TLS、Unix user、filesystem権限、cache、CSP headerの構築と変更は本リポジトリの対象外である。`deploy.ps1`はsudo、Caddy reload、`git pull`、remote buildを行わない。実VPSへの実行は明示的な運用操作として扱う。
## 15. 仕様との対応