mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 12:43:13 +00:00
438 lines
15 KiB
Bash
Executable file
438 lines
15 KiB
Bash
Executable file
#!/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."
|