From c0c2408e6830d82b5baad57e2b89d5c1e05dfc31 Mon Sep 17 00:00:00 2001 From: tanaka takashi Date: Wed, 26 Aug 2026 22:09:12 +0900 Subject: デプロイ用スクリプト追加 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + deploy.config.example.psd1 | 17 +++ deploy.ps1 | 314 +++++++++++++++++++++++++++++++++++++++++++++ doc/Design.md | 42 +++++- 4 files changed, 371 insertions(+), 3 deletions(-) create mode 100644 deploy.config.example.psd1 create mode 100644 deploy.ps1 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 | `/releases//` | +| 公開切替 | `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. 仕様との対応 -- cgit v1.2.3