shoc-frontend-new/scripts/deploy-web.sh

439 lines
15 KiB
Bash
Raw Normal View History

#!/usr/bin/env bash
set -Eeuo pipefail
required_vars=(
SITE_BUCKET
EXPECTED_SITE_BUCKET
CLOUDFRONT_DISTRIBUTION_ID
SITE_URL
EXPECTED_API_URL
)
for name in "${required_vars[@]}"; do
if [[ -z "${!name:-}" ]]; then
echo "::error::${name} must be set explicitly." >&2
exit 1
fi
done
DEPLOY_RELEASE_ID="${DEPLOY_RELEASE_ID:-${GITHUB_SHA:-}}"
EXTENSIONLESS_SMOKE_PATH="${EXTENSIONLESS_SMOKE_PATH:-/deployment-smoke}"
FORBIDDEN_API_URLS="${FORBIDDEN_API_URLS:-}"
API_SMOKE_URL="${API_SMOKE_URL:-}"
API_CORS_ORIGIN="${API_CORS_ORIGIN:-}"
if [[ "${SITE_BUCKET}" != "${EXPECTED_SITE_BUCKET}" ]]; then
echo "::error::SITE_BUCKET does not match EXPECTED_SITE_BUCKET." >&2
exit 1
fi
if [[ "${VITE_API_URL:-}" != "${EXPECTED_API_URL}" ]]; then
echo "::error::VITE_API_URL must exactly match EXPECTED_API_URL." >&2
exit 1
fi
if [[ ! "${DEPLOY_RELEASE_ID}" =~ ^[A-Za-z0-9._-]{7,128}$ ]]; then
echo "::error::DEPLOY_RELEASE_ID is missing or unsafe." >&2
exit 1
fi
if [[ ! "${SITE_URL}" =~ ^https://[^/]+/?$ || ! "${EXPECTED_API_URL}" =~ ^https:// ]]; then
echo "::error::SITE_URL and EXPECTED_API_URL must be HTTPS URLs." >&2
exit 1
fi
if [[ "${EXTENSIONLESS_SMOKE_PATH}" != /* || "${EXTENSIONLESS_SMOKE_PATH}" == *.* ]]; then
echo "::error::EXTENSIONLESS_SMOKE_PATH must be an extensionless absolute path." >&2
exit 1
fi
SITE_URL="${SITE_URL%/}"
API_CORS_ORIGIN="${API_CORS_ORIGIN:-${SITE_URL}}"
API_CORS_ORIGIN="${API_CORS_ORIGIN%/}"
work_dir="$(mktemp -d)"
published_index_version=""
prior_index_version=""
deployment_verified="false"
invalidate_and_wait() {
local invalidation_id
invalidation_id="$(
aws cloudfront create-invalidation \
--distribution-id "${CLOUDFRONT_DISTRIBUTION_ID}" \
--paths "/*" \
--query "Invalidation.Id" \
--output text
)"
test -n "${invalidation_id}"
aws cloudfront wait invalidation-completed \
--distribution-id "${CLOUDFRONT_DISTRIBUTION_ID}" \
--id "${invalidation_id}"
}
rollback_index() {
[[ -n "${published_index_version}" ]] || return 0
echo "::warning::Verification failed. Restoring the prior index version." >&2
if [[ -n "${prior_index_version}" && "${prior_index_version}" != "None" ]]; then
aws s3api copy-object \
--bucket "${SITE_BUCKET}" \
--key index.html \
--copy-source "${SITE_BUCKET}/index.html?versionId=${prior_index_version}" \
--cache-control "no-cache,no-store,must-revalidate" \
--content-type "text/html" \
--metadata-directive REPLACE >/dev/null
else
aws s3api delete-object \
--bucket "${SITE_BUCKET}" \
--key index.html \
--version-id "${published_index_version}" >/dev/null
fi
invalidate_and_wait || true
}
cleanup() {
local status=$?
if [[ "${status}" -ne 0 && "${deployment_verified}" != "true" ]]; then
rollback_index
fi
rm -rf "${work_dir}"
exit "${status}"
}
trap cleanup EXIT
versioning_status="$(
aws s3api get-bucket-versioning \
--bucket "${SITE_BUCKET}" \
--query Status \
--output text
)"
if [[ "${versioning_status}" != "Enabled" ]]; then
echo "::error::The target bucket must have versioning enabled." >&2
exit 1
fi
if ! prior_index_version="$(
aws s3api head-object \
--bucket "${SITE_BUCKET}" \
--key index.html \
--query VersionId \
--output text 2>"${work_dir}/prior-index.error"
)"; then
if grep -Eqi "(404|Not Found|NoSuchKey)" "${work_dir}/prior-index.error"; then
prior_index_version=""
else
cat "${work_dir}/prior-index.error" >&2
echo "::error::Could not inspect the current index version." >&2
exit 1
fi
fi
: >"${work_dir}/prior-asset-versions.tsv"
if [[ -n "${prior_index_version}" && "${prior_index_version}" != "None" ]]; then
prior_manifest_key="$(
aws s3api list-objects-v2 \
--bucket "${SITE_BUCKET}" \
--prefix ".deploy/releases/" \
--query "reverse(sort_by(Contents,&LastModified))[0].Key" \
--output text
)"
if [[ -n "${prior_manifest_key}" && "${prior_manifest_key}" != "None" ]]; then
aws s3 cp "s3://${SITE_BUCKET}/${prior_manifest_key}" \
"${work_dir}/prior-manifest.json" --quiet
node - "${work_dir}/prior-manifest.json" \
>"${work_dir}/prior-asset-versions.tsv" <<'NODE'
const manifest = require(process.argv[2]);
for (const asset of manifest.assets ?? []) {
if (typeof asset === "object" && asset.key && asset.versionId) {
console.log(`${asset.key}\t${asset.versionId}`);
}
}
NODE
else
aws s3api list-objects-v2 \
--bucket "${SITE_BUCKET}" \
--query "Contents[].Key" \
--output text | tr "\t" "\n" \
| awk '$0 != "index.html" && $0 !~ /^\.deploy\// && $0 != "None"' \
| sort -u >"${work_dir}/prior-asset-keys.txt"
while IFS= read -r prior_asset_key; do
[[ -n "${prior_asset_key}" ]] || continue
prior_asset_version="$(
aws s3api head-object \
--bucket "${SITE_BUCKET}" \
--key "${prior_asset_key}" \
--query VersionId \
--output text
)"
if [[ -z "${prior_asset_version}" || "${prior_asset_version}" == "None" ]]; then
echo "::error::Prior asset ${prior_asset_key} did not resolve to a version ID." >&2
exit 1
fi
printf "%s\t%s\n" "${prior_asset_key}" "${prior_asset_version}" \
>>"${work_dir}/prior-asset-versions.tsv"
done <"${work_dir}/prior-asset-keys.txt"
fi
fi
echo "Building SPA for ${EXPECTED_API_URL}..."
npm ci
npm run build
test -s dist/index.html
if ! grep -RqsF -- "${EXPECTED_API_URL}" dist; then
echo "::error::Built output does not contain EXPECTED_API_URL." >&2
exit 1
fi
for forbidden_url in ${FORBIDDEN_API_URLS//,/ }; do
if [[ -n "${forbidden_url}" ]] && grep -RqsF -- "${forbidden_url}" dist; then
echo "::error::Built output contains forbidden API URL ${forbidden_url}." >&2
exit 1
fi
done
echo "Publishing immutable release assets..."
aws s3 sync dist/ "s3://${SITE_BUCKET}/" \
--exclude "index.html" \
--cache-control "public,max-age=31536000,immutable"
published_index_version="$(
aws s3api put-object \
--bucket "${SITE_BUCKET}" \
--key index.html \
--body dist/index.html \
--cache-control "no-cache,no-store,must-revalidate" \
--content-type "text/html" \
--query VersionId \
--output text
)"
if [[ -z "${published_index_version}" || "${published_index_version}" == "None" ]]; then
echo "::error::Index upload did not return a version ID." >&2
exit 1
fi
node - >"${work_dir}/asset-keys.txt" <<'NODE'
const fs = require("node:fs");
const path = require("node:path");
function files(directory, prefix = "") {
return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const relative = path.posix.join(prefix, entry.name);
return entry.isDirectory()
? files(path.join(directory, entry.name), relative)
: [relative];
});
}
for (const file of files("dist").filter((entry) => entry !== "index.html").sort()) {
console.log(file);
}
NODE
: >"${work_dir}/asset-versions.tsv"
while IFS= read -r asset_key; do
asset_version="$(
aws s3api head-object \
--bucket "${SITE_BUCKET}" \
--key "${asset_key}" \
--query VersionId \
--output text
)"
if [[ -z "${asset_version}" || "${asset_version}" == "None" ]]; then
echo "::error::Asset ${asset_key} did not resolve to a version ID." >&2
exit 1
fi
printf "%s\t%s\n" "${asset_key}" "${asset_version}" >>"${work_dir}/asset-versions.tsv"
done <"${work_dir}/asset-keys.txt"
node - "${DEPLOY_RELEASE_ID}" "${published_index_version}" "${prior_index_version}" \
"${work_dir}/asset-versions.tsv" "${work_dir}/prior-asset-versions.tsv" \
>"${work_dir}/manifest.json" <<'NODE'
const fs = require("node:fs");
const [release, indexVersion, priorIndexVersion, versionsPath, priorVersionsPath] =
process.argv.slice(2);
function readVersions(path) {
return fs
.readFileSync(path, "utf8")
.trim()
.split("\n")
.filter(Boolean)
.map((line) => {
const [key, versionId] = line.split("\t");
return { key, versionId };
});
}
process.stdout.write(
`${JSON.stringify(
{
release,
indexVersion,
priorIndexVersion,
assets: readVersions(versionsPath),
priorAssets: readVersions(priorVersionsPath),
},
null,
2,
)}\n`,
);
NODE
manifest_key=".deploy/releases/${DEPLOY_RELEASE_ID}.json"
echo "Invalidating CloudFront and waiting for propagation..."
invalidate_and_wait
fetch_route() {
local route="$1"
local slug="$2"
curl -fsS --max-time 30 \
-D "${work_dir}/${slug}.headers" \
-o "${work_dir}/${slug}.body" \
"${SITE_URL}${route}"
grep -qi "^content-type:.*text/html" "${work_dir}/${slug}.headers"
grep -qi "^cache-control:.*no-cache" "${work_dir}/${slug}.headers"
grep -qi "^cache-control:.*no-store" "${work_dir}/${slug}.headers"
grep -qi "^cache-control:.*must-revalidate" "${work_dir}/${slug}.headers"
cmp -s "${work_dir}/${slug}.body" "${work_dir}/root.body"
}
curl -fsS --max-time 30 \
-D "${work_dir}/root.headers" \
-o "${work_dir}/root.body" \
"${SITE_URL}/"
grep -qi "^content-type:.*text/html" "${work_dir}/root.headers"
grep -qi "^cache-control:.*no-cache" "${work_dir}/root.headers"
grep -qi "^cache-control:.*no-store" "${work_dir}/root.headers"
grep -qi "^cache-control:.*must-revalidate" "${work_dir}/root.headers"
fetch_route "/login" "login"
fetch_route "${EXTENSIONLESS_SMOKE_PATH}" "extensionless"
grep -oE '(src|href)="/assets/[^"]+\.(js|css)"' "${work_dir}/root.body" \
| awk -F'"' '{ print $2 }' \
| sort -u >"${work_dir}/asset-paths.txt"
test -s "${work_dir}/asset-paths.txt"
: >"${work_dir}/asset-content.txt"
while IFS= read -r asset_path; do
asset_slug="$(printf "%s" "${asset_path}" | tr "/." "__")"
curl -fsS --max-time 30 \
-D "${work_dir}/${asset_slug}.headers" \
-o "${work_dir}/${asset_slug}.body" \
"${SITE_URL}${asset_path}"
grep -qi "^cache-control:.*max-age=31536000" "${work_dir}/${asset_slug}.headers"
grep -qi "^cache-control:.*immutable" "${work_dir}/${asset_slug}.headers"
cat "${work_dir}/${asset_slug}.body" >>"${work_dir}/asset-content.txt"
done <"${work_dir}/asset-paths.txt"
grep -qsF -- "${EXPECTED_API_URL}" "${work_dir}/asset-content.txt"
for forbidden_url in ${FORBIDDEN_API_URLS//,/ }; do
if [[ -n "${forbidden_url}" ]] &&
grep -qsF -- "${forbidden_url}" "${work_dir}/asset-content.txt"; then
echo "::error::Deployed assets contain forbidden API URL ${forbidden_url}." >&2
exit 1
fi
done
if [[ -n "${API_SMOKE_URL}" ]]; then
api_status="$(
curl -sS --max-time 30 \
-H "Origin: ${API_CORS_ORIGIN}" \
-D "${work_dir}/api.headers" \
-o "${work_dir}/api.body" \
-w "%{http_code}" \
"${API_SMOKE_URL}"
)"
if [[ "${api_status}" == "000" || "${api_status}" -ge 500 ]]; then
echo "::error::API smoke request failed with HTTP ${api_status}." >&2
exit 1
fi
grep -qi "^access-control-allow-origin: ${API_CORS_ORIGIN}" "${work_dir}/api.headers"
curl -fsS --max-time 30 \
-X OPTIONS \
-H "Origin: ${API_CORS_ORIGIN}" \
-H "Access-Control-Request-Method: GET" \
-D "${work_dir}/cors.headers" \
-o /dev/null \
"${API_SMOKE_URL}"
grep -qi "^access-control-allow-origin: ${API_CORS_ORIGIN}" "${work_dir}/cors.headers"
grep -qi "^access-control-allow-methods:.*GET" "${work_dir}/cors.headers"
fi
aws s3 cp "${work_dir}/manifest.json" "s3://${SITE_BUCKET}/${manifest_key}" \
--cache-control "no-cache,no-store,must-revalidate" \
--content-type "application/json"
# Once the manifest is durable, this release and its rollback target are both
# protected from pruning. A later cleanup failure must not roll back a release
# whose prior assets may already have been pruned.
deployment_verified="true"
# Keep exactly the current and immediately prior release manifests and every
# object version they reference. Prune only unreferenced versions, after all
# remote checks pass.
aws s3api list-objects-v2 \
--bucket "${SITE_BUCKET}" \
--prefix ".deploy/releases/" \
--query "reverse(sort_by(Contents,&LastModified))[].Key" \
--output text | tr "\t" "\n" >"${work_dir}/manifest-keys.txt"
printf "%s\n" "${manifest_key}" >"${work_dir}/kept-manifests.txt"
awk -v current="${manifest_key}" '$0 != current { print; exit }' \
"${work_dir}/manifest-keys.txt" >>"${work_dir}/kept-manifests.txt"
: >"${work_dir}/retained-versions.tsv"
while IFS= read -r kept_manifest; do
[[ -n "${kept_manifest}" ]] || continue
aws s3 cp "s3://${SITE_BUCKET}/${kept_manifest}" "${work_dir}/kept.json" --quiet
kept_manifest_version="$(
aws s3api head-object \
--bucket "${SITE_BUCKET}" \
--key "${kept_manifest}" \
--query VersionId \
--output text
)"
printf "%s\t%s\n" "${kept_manifest}" "${kept_manifest_version}" \
>>"${work_dir}/retained-versions.tsv"
is_current_manifest="false"
if [[ "${kept_manifest}" == "${manifest_key}" ]]; then
is_current_manifest="true"
fi
node - "${work_dir}/kept.json" "${is_current_manifest}" \
>>"${work_dir}/retained-versions.tsv" <<'NODE'
const manifest = require(process.argv[2]);
const isCurrentManifest = process.argv[3] === "true";
if (manifest.indexVersion && manifest.indexVersion !== "None") {
console.log(`index.html\t${manifest.indexVersion}`);
}
if (manifest.priorIndexVersion && manifest.priorIndexVersion !== "None") {
console.log(`index.html\t${manifest.priorIndexVersion}`);
}
for (const asset of manifest.assets) {
if (typeof asset === "object" && asset.key && asset.versionId) {
console.log(`${asset.key}\t${asset.versionId}`);
}
}
if (isCurrentManifest) {
for (const asset of manifest.priorAssets ?? []) {
if (typeof asset === "object" && asset.key && asset.versionId) {
console.log(`${asset.key}\t${asset.versionId}`);
}
}
}
NODE
done <"${work_dir}/kept-manifests.txt"
sort -u -o "${work_dir}/retained-versions.tsv" "${work_dir}/retained-versions.tsv"
aws s3api list-object-versions \
--bucket "${SITE_BUCKET}" \
--output json >"${work_dir}/object-versions.json"
node - "${work_dir}/object-versions.json" >"${work_dir}/prune-candidates.tsv" <<'NODE'
const listing = require(process.argv[2]);
for (const version of listing.Versions ?? []) {
console.log(`version\t${version.Key}\t${version.VersionId}`);
}
for (const marker of listing.DeleteMarkers ?? []) {
console.log(`marker\t${marker.Key}\t${marker.VersionId}`);
}
NODE
while IFS=$'\t' read -r kind object_key version_id; do
[[ -n "${object_key}" && -n "${version_id}" ]] || continue
if [[ "${kind}" == "version" ]] &&
grep -qxF -- "${object_key}"$'\t'"${version_id}" "${work_dir}/retained-versions.tsv"; then
continue
fi
aws s3api delete-object \
--bucket "${SITE_BUCKET}" \
--key "${object_key}" \
--version-id "${version_id}" >/dev/null
done <"${work_dir}/prune-candidates.tsv"
echo "Web release ${DEPLOY_RELEASE_ID} deployed and verified."