diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml
index 61184d2b..496951fc 100644
--- a/.github/workflows/deploy-site.yml
+++ b/.github/workflows/deploy-site.yml
@@ -4,27 +4,10 @@ run-name: Deploy project site ${{ inputs.release_tag || github.ref_name }} ${{ i
on:
push:
branches: [main, master]
- paths:
- - "apps/site/**"
- - "apps/flutter/**"
- - "packages/**"
- - "scripts/check-release-publication.mjs"
- - "scripts/build-demo.mjs"
- - "scripts/deploy-site.mjs"
- - "scripts/deploy-demo.mjs"
- - "scripts/dispatch-site-deployment.mjs"
- - "scripts/generate-release-manifest.mjs"
- - "scripts/inspect-linux-update.mjs"
- - "scripts/read-bounded-response.mjs"
- - "scripts/reconcile-release-assets.mjs"
- - "scripts/wait-for-release-assets.mjs"
- - "package.json"
- - "pnpm-lock.yaml"
- - ".github/workflows/deploy-site.yml"
workflow_dispatch:
inputs:
release_tag:
- description: Published release tag whose immutable source must be deployed.
+ description: Published release tag whose immutable source must be verified.
required: true
type: string
dispatch_nonce:
@@ -34,87 +17,19 @@ on:
permissions:
contents: read
- id-token: write
concurrency:
- group: t4code-net-production
- cancel-in-progress: false
+ group: t4code-com-production-${{ github.event_name }}
+ cancel-in-progress: ${{ github.event_name == 'push' }}
jobs:
- demo:
- if: ${{ github.event_name == 'push' }}
+ verify-production:
+ if: ${{ (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'workflow_dispatch' && startsWith(github.ref, 'refs/tags/')) }}
runs-on: ubuntu-24.04
timeout-minutes: 30
environment:
name: production
- url: https://t4code.net/demo
- steps:
- - name: Check out current demo source
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- with:
- ref: ${{ github.sha }}
- persist-credentials: false
-
- - name: Install pnpm
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
- with:
- version: 11.10.0
-
- - name: Install Node.js
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
- with:
- node-version: 24.13.1
-
- - name: Install Flutter
- uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
- with:
- flutter-version: 3.44.6
- channel: stable
- cache: true
-
- - name: Install dependencies
- run: pnpm install --frozen-lockfile
-
- - name: Authenticate to AWS with GitHub OIDC
- uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4
- with:
- role-to-assume: ${{ vars.AWS_ROLE_ARN }}
- aws-region: us-east-1
-
- - name: Check whether the live demo permits the Flutter renderer
- id: demo_csp
- shell: bash
- run: |
- set -euo pipefail
- headers=$(curl --fail --silent --show-error --head https://t4code.net/demo)
- if printf '%s\n' "$headers" \
- | tr -d '\r' \
- | grep -i '^content-security-policy:' \
- | grep -Fq "'wasm-unsafe-eval'"; then
- echo "ready=true" >> "$GITHUB_OUTPUT"
- else
- echo "ready=false" >> "$GITHUB_OUTPUT"
- echo "::warning::Flutter demo deployment is deferred until the demo CloudFront CSP permits wasm-unsafe-eval."
- fi
-
- - name: Defer Flutter demo until its response policy is active
- if: ${{ steps.demo_csp.outputs.ready != 'true' }}
- run: echo "Apply infra/site/cloudformation.yml, then rerun this workflow from a main push."
-
- - name: Build and deploy current Flutter demo
- if: ${{ steps.demo_csp.outputs.ready == 'true' }}
- env:
- T4_SITE_BUCKET: ${{ vars.T4_SITE_BUCKET }}
- T4_CLOUDFRONT_DISTRIBUTION_ID: ${{ vars.T4_CLOUDFRONT_DISTRIBUTION_ID }}
- run: pnpm deploy:demo
-
- deploy:
- if: ${{ github.event_name != 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/') }}
- runs-on: ubuntu-24.04
- timeout-minutes: 50
- environment:
- name: production
- url: https://t4code.net
+ url: https://t4code.com
steps:
- name: Check out trusted workflow source
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
@@ -123,122 +38,117 @@ jobs:
fetch-depth: 0
persist-credentials: false
- - name: Read trusted workflow source version
- id: source
- shell: bash
- env:
- TRUSTED_SHA: ${{ github.sha }}
- run: |
- set -euo pipefail
- version=$(node -p "require('./package.json').version")
- printf 'version=%s\ntrusted_sha=%s\n' "$version" "$TRUSTED_SHA" >> "$GITHUB_OUTPUT"
-
- - name: Install pnpm
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
- with:
- version: 11.10.0
-
- - name: Install Node.js
+ - name: Install Node.js for immutable-source checks
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 24.13.1
- - name: Confirm the published release from the release workflow
- id: published_release
- if: ${{ github.event_name == 'workflow_dispatch' }}
- env:
- RELEASE_VERSION: ${{ steps.source.outputs.version }}
- run: node scripts/wait-for-release-assets.mjs --version "$RELEASE_VERSION" --timeout-ms 2400000 --interval-ms 15000
-
- name: Classify the stable release referenced by an ordinary main push
- id: release_state
if: ${{ github.event_name == 'push' }}
+ id: release_state
env:
GH_TOKEN: ${{ github.token }}
- RELEASE_VERSION: ${{ steps.source.outputs.version }}
- run: node scripts/check-release-publication.mjs --version "$RELEASE_VERSION" --github-output "$GITHUB_OUTPUT"
-
- - name: Confirm assets for an existing stable release
- id: existing_release
- if: ${{ github.event_name == 'push' && steps.release_state.outputs.state == 'published' }}
- env:
- RELEASE_VERSION: ${{ steps.source.outputs.version }}
- run: node scripts/wait-for-release-assets.mjs --version "$RELEASE_VERSION" --timeout-ms 15000 --interval-ms 3000
+ shell: bash
+ run: |
+ set -euo pipefail
+ RELEASE_VERSION=$(node -p "require('./package.json').version")
+ node scripts/check-release-publication.mjs --version "$RELEASE_VERSION" --github-output "$GITHUB_OUTPUT"
- - name: Defer a release-version site update until publication
+ - name: Record that the ordinary main source is not yet a published release
if: ${{ github.event_name == 'push' && steps.release_state.outputs.state == 'not-published' }}
- run: echo "The referenced release is not public yet; the release workflow will deploy this site after publication."
+ run: echo "The mirrored main push is still expected to deploy this exact source revision."
- name: Resolve immutable deployment source
id: immutable_source
- if: ${{ steps.published_release.outcome == 'success' || steps.existing_release.outcome == 'success' }}
shell: bash
env:
EVENT_NAME: ${{ github.event_name }}
GH_TOKEN: ${{ github.token }}
- TRUSTED_SHA: ${{ steps.source.outputs.trusted_sha }}
REQUESTED_RELEASE_TAG: ${{ inputs.release_tag }}
- TRUSTED_VERSION: ${{ steps.source.outputs.version }}
+ TRUSTED_SHA: ${{ github.sha }}
run: |
set -euo pipefail
+ TRUSTED_VERSION=$(node -p "require('./package.json').version")
expected_tag="v${TRUSTED_VERSION}"
release_tag="$expected_tag"
+ source_sha="$TRUSTED_SHA"
+
if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then
if [[ "$REQUESTED_RELEASE_TAG" != "$expected_tag" ]]; then
- echo "release_tag must be the current release ${expected_tag}" >&2
+ echo "release deployment requested an unexpected tag" >&2
exit 1
fi
if [[ "$GITHUB_REF" != "refs/tags/${expected_tag}" ]]; then
- echo "workflow_dispatch must run from the immutable release tag ${expected_tag}" >&2
+ echo "release deployment must run from the current immutable tag ${expected_tag}" >&2
exit 1
fi
- release_tag="$REQUESTED_RELEASE_TAG"
- fi
- release_flags=$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${release_tag}" --jq '[.draft, .prerelease] | @tsv')
- if [[ "$release_flags" != $'false\tfalse' ]]; then
- echo "release_tag must name a published, non-prerelease GitHub release" >&2
- exit 1
- fi
- git fetch --force origin "refs/tags/${release_tag}:refs/tags/${release_tag}"
- source_sha=$(git rev-parse "${release_tag}^{commit}")
- tag_version=$(git show "${source_sha}:package.json" | jq -er '.version')
- if [[ "$tag_version" != "$TRUSTED_VERSION" ]]; then
- echo "release tag package version ${tag_version} does not match trusted source ${TRUSTED_VERSION}" >&2
- exit 1
- fi
- if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then
+ release_flags=$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${release_tag}" --jq '[.draft, .prerelease] | @tsv')
+ if [[ "$release_flags" != $'false\tfalse' ]]; then
+ echo "release_tag must name a published, non-prerelease GitHub release" >&2
+ exit 1
+ fi
+ git fetch --force origin "refs/tags/${expected_tag}:refs/tags/${expected_tag}"
+ source_sha=$(git rev-parse "${expected_tag}^{commit}")
if [[ "$source_sha" != "$TRUSTED_SHA" ]]; then
- echo "release tag moved after this immutable deployment was dispatched" >&2
+ echo "release tag moved after this deployment was dispatched" >&2
exit 1
fi
- elif ! git merge-base --is-ancestor "$source_sha" "$TRUSTED_SHA"; then
- echo "release tag source is not reachable from the trusted workflow source" >&2
+ fi
+
+ if [[ ! "$source_sha" =~ ^[0-9a-f]{40}$ ]]; then
+ echo "deployment source must be an exact lowercase commit SHA" >&2
exit 1
fi
- printf 'release_tag=%s\nsource_sha=%s\n' "$release_tag" "$source_sha" >> "$GITHUB_OUTPUT"
+ git merge-base --is-ancestor "$source_sha" "$TRUSTED_SHA"
+ git fetch --force origin "refs/heads/main:refs/remotes/origin/main"
+ git merge-base --is-ancestor "$source_sha" refs/remotes/origin/main
+ printf 'source_sha=%s\nrelease_tag=%s\nversion=%s\n' "$source_sha" "$release_tag" "$TRUSTED_VERSION" >> "$GITHUB_OUTPUT"
- name: Check out immutable deployment source
- if: ${{ steps.published_release.outcome == 'success' || steps.existing_release.outcome == 'success' }}
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: ${{ steps.immutable_source.outputs.source_sha }}
persist-credentials: false
+ - name: Install pnpm
+ uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
+ with:
+ version: 11.10.0
+
+ - name: Install immutable-source Node.js
+ uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
+ with:
+ node-version: 24.13.1
+
- name: Install dependencies
- if: ${{ steps.published_release.outcome == 'success' || steps.existing_release.outcome == 'success' }}
run: pnpm install --frozen-lockfile
- - name: Authenticate to AWS with GitHub OIDC
- if: ${{ steps.published_release.outcome == 'success' || steps.existing_release.outcome == 'success' }}
- uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4
- with:
- role-to-assume: ${{ vars.AWS_ROLE_ARN }}
- aws-region: us-east-1
+ - name: Test deployment verification contracts
+ run: pnpm test:deploy:site
- - name: Build and deploy immutable release site
- if: ${{ steps.published_release.outcome == 'success' || steps.existing_release.outcome == 'success' }}
+ - name: Wait for the immutable release bundle
+ if: ${{ github.event_name == 'workflow_dispatch' }}
env:
- GH_TOKEN: ${{ github.token }}
- T4_SITE_BUCKET: ${{ vars.T4_SITE_BUCKET }}
- T4_CLOUDFRONT_DISTRIBUTION_ID: ${{ vars.T4_CLOUDFRONT_DISTRIBUTION_ID }}
- run: pnpm deploy:site
+ RELEASE_VERSION: ${{ steps.immutable_source.outputs.version }}
+ run: node scripts/wait-for-release-assets.mjs --version "$RELEASE_VERSION" --timeout-ms 2400000 --interval-ms 15000
+
+ - name: Validate the site build
+ shell: bash
+ run: |
+ set -euo pipefail
+ version=$(node -p "require('./package.json').version")
+ node scripts/generate-release-manifest.mjs \
+ --version "$version" \
+ --output apps/site/public/releases/latest.json
+ pnpm build:site
+
+ - name: Wait for the mirrored deployment at the public edge
+ env:
+ SOURCE_SHA: ${{ steps.immutable_source.outputs.source_sha }}
+ run: >-
+ node scripts/verify-site-revision.mjs
+ --target public
+ --expected "$SOURCE_SHA"
+ --timeout-ms 900000
+ --interval-ms 10000
+ --request-timeout-ms 5000
diff --git a/.gitignore b/.gitignore
index 1e0549d8..eba139f9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -28,3 +28,9 @@ test-results/
/output/playwright/
*.log
.todos/
+
+# Local Terraform runtime artifacts
+.terraform/
+*.tfplan
+*.tfstate
+*.tfstate.*
diff --git a/.woodpecker.yml b/.woodpecker.yml
index 98024327..c21f921b 100644
--- a/.woodpecker.yml
+++ b/.woodpecker.yml
@@ -335,6 +335,133 @@ steps:
memory: 1Gi
ephemeral-storage: 1Gi
+ harbor-auth-site:
+ depends_on: [dependencies]
+ image: harbor.tailb18de3.ts.net/linkedin-bot/woodpecker-buildkit-deploy-tools@sha256:7936be1edac8ce3c225b148cff0e89b1446fc88185d72379398617f7279ad8cc
+ commands:
+ - sh scripts/cluster-ci/load-registry-auth.sh
+ when:
+ - event: push
+ branch: main
+ - event: manual
+ branch: main
+ backend_options:
+ kubernetes:
+ serviceAccountName: woodpecker-dev-verifier
+ resources:
+ requests:
+ cpu: "0"
+ memory: 128Mi
+ ephemeral-storage: 128Mi
+ limits:
+ cpu: 500m
+ memory: 512Mi
+ ephemeral-storage: 512Mi
+
+
+ build-site:
+ depends_on: [harbor-auth-site]
+ image: mirror.gcr.io/moby/buildkit:v0.30.0-rootless@sha256:d76eb1caecac5733ef7553c1e90a1b21f1bb218cd1142d3553de0747b4a14ba9
+ environment:
+ BUILDKIT_ADDR: tcp://woodpecker-buildkitd.linkedin-ci.svc.cluster.local:1234
+ HARBOR_REGISTRY: harbor.tailb18de3.ts.net
+ HARBOR_PROJECT: linkedin-bot
+ commands:
+ - scripts/build-site-image.sh
+ when:
+ - event: push
+ branch: main
+ - event: manual
+ branch: main
+ backend_options:
+ kubernetes:
+ serviceAccountName: woodpecker-ci-untrusted
+ resources:
+ requests:
+ cpu: "0"
+ memory: 256Mi
+ ephemeral-storage: 256Mi
+ limits:
+ cpu: "1"
+ memory: 1Gi
+ ephemeral-storage: 1Gi
+
+ promote-site:
+ depends_on: [build-site]
+ image: ghcr.io/oras-project/oras:v1.3.0@sha256:6ce045ce069a89934d6666b8b49f9c4c0145201bd6de6dbe2aee267814c55468
+ environment:
+ HARBOR_REGISTRY: harbor.tailb18de3.ts.net
+ HARBOR_PROJECT: linkedin-bot
+ commands:
+ - scripts/promote-site-image.sh
+ when:
+ - event: push
+ branch: main
+ - event: manual
+ branch: main
+ backend_options:
+ kubernetes:
+ serviceAccountName: woodpecker-ci-untrusted
+ resources:
+ requests:
+ cpu: "0"
+ memory: 128Mi
+ ephemeral-storage: 128Mi
+ limits:
+ cpu: 500m
+ memory: 512Mi
+ ephemeral-storage: 512Mi
+
+ deploy-site:
+ depends_on: [promote-site]
+ image: harbor.tailb18de3.ts.net/linkedin-bot/woodpecker-buildkit-deploy-tools@sha256:7936be1edac8ce3c225b148cff0e89b1446fc88185d72379398617f7279ad8cc
+ environment:
+ T4_SITE_IMAGE_REPOSITORY: harbor.tailb18de3.ts.net/linkedin-bot/t4-site
+ commands:
+ - export T4_SITE_IMAGE_DIGEST="$(cat .site-ci/site-image-digest)" && node scripts/deploy-site.mjs
+ when:
+ - event: push
+ branch: main
+ - event: manual
+ branch: main
+ backend_options:
+ kubernetes:
+ serviceAccountName: woodpecker-dev-verifier
+ resources:
+ requests:
+ cpu: "0"
+ memory: 128Mi
+ ephemeral-storage: 128Mi
+ limits:
+ cpu: 500m
+ memory: 512Mi
+ ephemeral-storage: 512Mi
+
+ cleanup-site-registry-auth:
+ depends_on: [deploy-site]
+ image: mirror.gcr.io/library/busybox:1.37.0-musl@sha256:222ad6d973c0d198014546a65cd02c5fdedcc172123c5b4c2bf0af636550bd94
+ commands:
+ - rm -rf .site-ci
+ when:
+ - event: push
+ branch: main
+ status: [success, failure]
+ - event: manual
+ branch: main
+ status: [success, failure]
+ backend_options:
+ kubernetes:
+ serviceAccountName: woodpecker-ci-untrusted
+ resources:
+ requests:
+ cpu: "0"
+ memory: 16Mi
+ ephemeral-storage: 16Mi
+ limits:
+ cpu: 100m
+ memory: 64Mi
+ ephemeral-storage: 64Mi
+
sbom-controller:
depends_on: [build-session-runtime]
image: anchore/syft:v1.33.0-debug@sha256:421a1367ee81b35b094defc7c1c42774e9f0c2f818e278848175015987c4a034
diff --git a/apps/site/docs/index.html b/apps/site/docs/index.html
index b2ecdb6e..1ac8d219 100644
--- a/apps/site/docs/index.html
+++ b/apps/site/docs/index.html
@@ -9,7 +9,7 @@
name="description"
content="Documentation for T4 Code: install, first run, sessions, remote pairing, agents, terminals, settings, keyboard shortcuts, troubleshooting, security, and building from source."
/>
-
+
@@ -21,7 +21,7 @@
property="og:description"
content="Install T4 Code, pair it with an Oh My Pi host, and get the most out of sessions, agents, and terminals."
/>
-
+
diff --git a/apps/site/index.html b/apps/site/index.html
index 01f3f92d..cb93c11f 100644
--- a/apps/site/index.html
+++ b/apps/site/index.html
@@ -4,25 +4,25 @@
- T4 Code — the open-source desktop app for Oh My Pi
+ T4 Code — the open-source client for Oh My Pi
-
+
-
+
-
-
+
+
diff --git a/apps/site/public/robots.txt b/apps/site/public/robots.txt
index fa69a55a..e4be210e 100644
--- a/apps/site/public/robots.txt
+++ b/apps/site/public/robots.txt
@@ -1,4 +1,4 @@
User-agent: *
Allow: /
-Sitemap: https://t4code.net/sitemap.xml
+Sitemap: https://t4code.com/sitemap.xml
diff --git a/apps/site/public/site.webmanifest b/apps/site/public/site.webmanifest
index abd093da..d2b1fdfa 100644
--- a/apps/site/public/site.webmanifest
+++ b/apps/site/public/site.webmanifest
@@ -1,7 +1,7 @@
{
"name": "T4 Code",
"short_name": "T4 Code",
- "description": "The open-source desktop app for Oh My Pi.",
+ "description": "The open-source client for Oh My Pi.",
"start_url": "/",
"display": "browser",
"background_color": "#161616",
diff --git a/apps/site/public/sitemap.xml b/apps/site/public/sitemap.xml
index 0c8264c3..9de128d0 100644
--- a/apps/site/public/sitemap.xml
+++ b/apps/site/public/sitemap.xml
@@ -1,9 +1,9 @@
- https://t4code.net/
+ https://t4code.com/
- https://t4code.net/docs/
+ https://t4code.com/docs/
diff --git a/apps/site/src/docs/content.ts b/apps/site/src/docs/content.ts
index bd95867c..9fa3ced1 100644
--- a/apps/site/src/docs/content.ts
+++ b/apps/site/src/docs/content.ts
@@ -579,7 +579,7 @@ const security: DocTopic = {
title: "Security",
lede: "Plain answers about what T4 Code stores, sends, and never does.",
blocks: [
- { kind: "h2", id: "security-unsigned", text: "Signed macOS releases" },
+ { kind: "h2", id: "security-signing", text: "Signed macOS releases" },
{
kind: "p",
text: `The v${RELEASE_VERSION} macOS build is signed with the project's pinned Developer ID identity and notarized by Apple. Publication stops if the certificate, Team ID, hardened runtime, secure timestamp, stapled ticket, or Gatekeeper result drifts. You can also [build from source](#build-from-source). The repository is public at [LycaonLLC/t4-code](${REPO_URL}).`,
@@ -597,7 +597,7 @@ const security: DocTopic = {
{ kind: "h2", id: "security-site", text: "This website" },
{
kind: "p",
- text: "t4code.net is a static site. It loads no third-party scripts, no analytics, and no trackers; fonts are bundled with the site.",
+ text: "t4code.com is a static site. It loads no third-party scripts, no analytics, and no trackers; fonts are bundled with the site.",
},
],
};
diff --git a/apps/site/src/landing/Landing.tsx b/apps/site/src/landing/Landing.tsx
index 4ea1015b..2563c319 100644
--- a/apps/site/src/landing/Landing.tsx
+++ b/apps/site/src/landing/Landing.tsx
@@ -246,10 +246,11 @@ export function Landing() {
- Heads up: the v{RELEASE_VERSION} macOS build is unsigned and not
- notarized, so macOS warns before first launch. If you would rather not run an unsigned
- binary, {renderInline(`[build it from source](/docs/#build-from-source)`)}. The whole
- app is public.
+ Verified for macOS: the v{RELEASE_VERSION} release is signed with the
+ project's Developer ID identity and notarized by Apple. If Gatekeeper blocks it,
+ delete that copy and verify a fresh download against the release checksum instead of
+ bypassing the warning. {renderInline(`[First-launch help](/docs/#install-gatekeeper)`)}
+ .
diff --git a/apps/site/src/release.ts b/apps/site/src/release.ts
index 898dbba3..10c6dd92 100644
--- a/apps/site/src/release.ts
+++ b/apps/site/src/release.ts
@@ -1,7 +1,7 @@
// Single source of truth for the public release: repo, tag, asset names,
// download URLs, and the platform-detection rule the hero button uses.
-export const SITE_URL = "https://t4code.net";
+export const SITE_URL = "https://t4code.com";
export const DOCS_URL = `${SITE_URL}/docs`;
export const REPO_URL = "https://github.com/LycaonLLC/t4-code";
export const OMP_URL = "https://github.com/can1357/oh-my-pi";
diff --git a/apps/site/src/site.css b/apps/site/src/site.css
index e979e409..a0fb3606 100644
--- a/apps/site/src/site.css
+++ b/apps/site/src/site.css
@@ -3,7 +3,7 @@
@import "@fontsource/jetbrains-mono/600.css";
/*
- * t4code.net stylesheet. Token values mirror packages/ui/src/tokens.css
+ * t4code.com stylesheet. Token values mirror packages/ui/src/tokens.css
* (dark theme); this is the only site file with raw color values.
*/
diff --git a/apps/site/test/release.test.ts b/apps/site/test/release.test.ts
index b973b998..ca033000 100644
--- a/apps/site/test/release.test.ts
+++ b/apps/site/test/release.test.ts
@@ -40,7 +40,7 @@ describe("release assets", () => {
expect(REPO_URL).toBe("https://github.com/LycaonLLC/t4-code");
expect(RELEASE_TAG).toBe("v0.1.30");
expect(RELEASE_VERSION).toBe("0.1.30");
- expect(RELEASE_MANIFEST_URL).toBe("https://t4code.net/releases/latest.json");
+ expect(RELEASE_MANIFEST_URL).toBe("https://t4code.com/releases/latest.json");
});
it("splits assets by platform with correct architectures", () => {
diff --git a/cluster/images/site/Dockerfile b/cluster/images/site/Dockerfile
new file mode 100644
index 00000000..c2d7a59f
--- /dev/null
+++ b/cluster/images/site/Dockerfile
@@ -0,0 +1,71 @@
+# syntax=docker/dockerfile:1.7
+FROM docker.io/library/node:24.13.1-bookworm@sha256:00e9195ebd49985a6da8921f419978d85dfe354589755192dc090425ce4da2f7 AS build
+WORKDIR /opt/t4
+
+COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./
+COPY apps/site/package.json apps/site/package.json
+RUN corepack enable \
+ && pnpm install --filter @t4-code/site... --frozen-lockfile
+
+COPY apps/site/ apps/site/
+RUN pnpm --filter @t4-code/site build
+ARG SOURCE_COMMIT
+RUN test "${#SOURCE_COMMIT}" -eq 40 \
+ && case "${SOURCE_COMMIT}" in *[!0-9a-f]*) exit 1 ;; esac \
+ && printf '{"revision":"%s"}\n' "${SOURCE_COMMIT}" > apps/site/dist/revision.json
+
+FROM docker.io/nginxinc/nginx-unprivileged:1.29.4-alpine3.23@sha256:a6c4f61f456b85b8fdf7ec7ab28cc3e299440e6fb4a9dea520e5fd8fd440025e
+ARG SOURCE_REPOSITORY=https://github.com/LycaonLLC/t4-code
+ARG SOURCE_COMMIT
+LABEL org.opencontainers.image.source="${SOURCE_REPOSITORY}" \
+ org.opencontainers.image.revision="${SOURCE_COMMIT}" \
+ org.opencontainers.image.title="T4 Code website"
+
+COPY <<'EOF' /etc/nginx/conf.d/default.conf
+server {
+ listen 8080;
+ listen [::]:8080;
+ server_name _;
+
+ root /usr/share/nginx/html;
+ index index.html;
+ charset utf-8;
+ server_tokens off;
+
+ location = /healthz {
+ access_log off;
+ default_type text/plain;
+ add_header Cache-Control "no-store" always;
+ return 200 "ok\n";
+ }
+
+ location = /revision.json {
+ try_files $uri =404;
+ add_header Cache-Control "no-store" always;
+ }
+
+ location /assets/ {
+ try_files $uri =404;
+ add_header Cache-Control "public, max-age=31536000, immutable" always;
+ }
+
+ location ~* \.html$ {
+ try_files $uri =404;
+ add_header Cache-Control "no-cache, no-store, must-revalidate" always;
+ }
+
+ location / {
+ try_files $uri $uri/ =404;
+ add_header Cache-Control "public, max-age=3600" always;
+ }
+
+ error_page 404 /404.html;
+}
+EOF
+
+COPY --from=build --chown=101:101 /opt/t4/apps/site/dist/ /usr/share/nginx/html/
+
+USER 101:101
+EXPOSE 8080
+ENTRYPOINT []
+CMD ["nginx", "-g", "daemon off;"]
diff --git a/deploy/bootstrap/t4-site-deployer.yaml b/deploy/bootstrap/t4-site-deployer.yaml
new file mode 100644
index 00000000..713fed5d
--- /dev/null
+++ b/deploy/bootstrap/t4-site-deployer.yaml
@@ -0,0 +1,44 @@
+apiVersion: v1
+kind: Namespace
+metadata:
+ name: t4-site
+ labels:
+ pod-security.kubernetes.io/enforce: restricted
+ pod-security.kubernetes.io/enforce-version: latest
+ pod-security.kubernetes.io/audit: restricted
+ pod-security.kubernetes.io/audit-version: latest
+ pod-security.kubernetes.io/warn: restricted
+ pod-security.kubernetes.io/warn-version: latest
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: Role
+metadata:
+ name: t4-site-deployer
+ namespace: t4-site
+rules:
+ - apiGroups: [""]
+ resources: ["secrets", "services"]
+ verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
+ - apiGroups: ["apps"]
+ resources: ["deployments"]
+ verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
+ - apiGroups: ["apps"]
+ resources: ["replicasets"]
+ verbs: ["get", "list", "watch"]
+ - apiGroups: ["networking.k8s.io"]
+ resources: ["ingresses"]
+ verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: RoleBinding
+metadata:
+ name: t4-site-deployer
+ namespace: t4-site
+subjects:
+ - kind: ServiceAccount
+ name: woodpecker-dev-verifier
+ namespace: linkedin-ci
+roleRef:
+ apiGroup: rbac.authorization.k8s.io
+ kind: Role
+ name: t4-site-deployer
diff --git a/deploy/bootstrap/t4-site-upstream-mirror.yaml b/deploy/bootstrap/t4-site-upstream-mirror.yaml
new file mode 100644
index 00000000..bb4bbf7d
--- /dev/null
+++ b/deploy/bootstrap/t4-site-upstream-mirror.yaml
@@ -0,0 +1,100 @@
+apiVersion: batch/v1
+kind: CronJob
+metadata:
+ name: t4-site-upstream-mirror
+ namespace: linkedin-ci
+spec:
+ schedule: "* * * * *"
+ concurrencyPolicy: Forbid
+ successfulJobsHistoryLimit: 1
+ failedJobsHistoryLimit: 3
+ jobTemplate:
+ spec:
+ activeDeadlineSeconds: 120
+ backoffLimit: 2
+ ttlSecondsAfterFinished: 600
+ template:
+ metadata:
+ labels:
+ app.kubernetes.io/name: t4-site-upstream-mirror
+ app.kubernetes.io/part-of: t4-site
+ spec:
+ automountServiceAccountToken: false
+ restartPolicy: Never
+ securityContext:
+ runAsNonRoot: true
+ runAsUser: 65532
+ runAsGroup: 65532
+ fsGroup: 65532
+ seccompProfile:
+ type: RuntimeDefault
+ containers:
+ - name: mirror
+ image: mirror.gcr.io/alpine/git:2.49.1@sha256:c0280cf9572316299b08544065d3bf35db65043d5e3963982ec50647d2746e26
+ imagePullPolicy: IfNotPresent
+ command: ["/bin/sh", "-ec"]
+ args:
+ - |
+ worktree="$(mktemp -d /tmp/t4-site-mirror.XXXXXX)"
+ trap 'rm -rf "${worktree}" /tmp/.gitconfig /tmp/git-credentials' EXIT
+ git config --global credential.helper 'store --file=/tmp/git-credentials'
+ printf 'protocol=https\nhost=gitlab.com\nusername=%s\npassword=%s\n\n' \
+ "${GITLAB_USERNAME}" "${GITLAB_TOKEN}" | git credential approve
+ git clone --branch main --single-branch --no-tags \
+ https://github.com/LycaonLLC/t4-code.git "${worktree}/repository"
+ git -C "${worktree}/repository" remote add gitlab \
+ https://gitlab.com/z-peterson/t4-code.git
+ commit="$(git -C "${worktree}/repository" rev-parse HEAD)"
+ current="$(git ls-remote --heads gitlab refs/heads/main | awk '{print $1}')"
+ if [ "${current}" = "${commit}" ]; then
+ printf 'GitLab main already matches upstream revision %s.\n' "${commit}"
+ exit 0
+ fi
+ git -C "${worktree}/repository" push --force gitlab \
+ HEAD:refs/heads/main
+ payload="$(printf '{\"repo\":\"z-peterson/t4-code\",\"branch\":\"main\",\"commit\":\"%s\",\"event\":\"manual\",\"environment\":\"development\"}' "${commit}")"
+ wget --quiet --timeout=90 --tries=1 --output-document=- \
+ --header="Authorization: Bearer ${WOODPECKER_TRIGGER_TOKEN}" \
+ --header='Content-Type: application/json' \
+ --post-data="${payload}" \
+ http://woodpecker-ci-trigger.linkedin-ci.svc.cluster.local/trigger
+ printf '\n'
+ env:
+ - name: HOME
+ value: /tmp
+ - name: GIT_TERMINAL_PROMPT
+ value: "0"
+ - name: GITLAB_USERNAME
+ valueFrom:
+ secretKeyRef:
+ name: t4-site-mirror-gitlab
+ key: username
+ - name: GITLAB_TOKEN
+ valueFrom:
+ secretKeyRef:
+ name: t4-site-mirror-gitlab
+ key: token
+ - name: WOODPECKER_TRIGGER_TOKEN
+ valueFrom:
+ secretKeyRef:
+ name: gitlab-maintenance-secrets
+ key: GITLAB_API_TOKEN
+ resources:
+ requests:
+ cpu: 25m
+ memory: 32Mi
+ limits:
+ cpu: 250m
+ memory: 256Mi
+ securityContext:
+ allowPrivilegeEscalation: false
+ readOnlyRootFilesystem: true
+ capabilities:
+ drop: ["ALL"]
+ volumeMounts:
+ - name: temporary
+ mountPath: /tmp
+ volumes:
+ - name: temporary
+ emptyDir:
+ sizeLimit: 1Gi
diff --git a/deploy/charts/t4-site/Chart.yaml b/deploy/charts/t4-site/Chart.yaml
new file mode 100644
index 00000000..2cd43d96
--- /dev/null
+++ b/deploy/charts/t4-site/Chart.yaml
@@ -0,0 +1,7 @@
+apiVersion: v2
+name: t4-site
+description: T4 Code production website
+version: 0.1.0
+appVersion: "0.1.30"
+type: application
+kubeVersion: ">=1.30.0-0"
diff --git a/deploy/charts/t4-site/templates/_helpers.tpl b/deploy/charts/t4-site/templates/_helpers.tpl
new file mode 100644
index 00000000..d9d52932
--- /dev/null
+++ b/deploy/charts/t4-site/templates/_helpers.tpl
@@ -0,0 +1,38 @@
+{{- define "t4-site.name" -}}
+{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
+{{- end -}}
+
+{{- define "t4-site.fullname" -}}
+{{- if .Values.fullnameOverride -}}
+{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
+{{- else if contains (include "t4-site.name" .) .Release.Name -}}
+{{- .Release.Name | trunc 63 | trimSuffix "-" -}}
+{{- else -}}
+{{- printf "%s-%s" .Release.Name (include "t4-site.name" .) | trunc 63 | trimSuffix "-" -}}
+{{- end -}}
+{{- end -}}
+
+{{- define "t4-site.chart" -}}
+{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}}
+{{- end -}}
+
+{{- define "t4-site.labels" -}}
+helm.sh/chart: {{ include "t4-site.chart" . | quote }}
+{{ include "t4-site.selectorLabels" . }}
+app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
+app.kubernetes.io/part-of: "t4-site"
+app.kubernetes.io/managed-by: {{ .Release.Service | quote }}
+{{- end -}}
+
+{{- define "t4-site.selectorLabels" -}}
+app.kubernetes.io/name: {{ include "t4-site.name" . | quote }}
+app.kubernetes.io/instance: {{ .Release.Name | quote }}
+{{- end -}}
+
+{{- define "t4-site.image" -}}
+{{- if .Values.image.digest -}}
+{{- printf "%s@%s" .Values.image.repository .Values.image.digest -}}
+{{- else -}}
+{{- printf "%s:%s" .Values.image.repository (default .Chart.AppVersion .Values.image.tag) -}}
+{{- end -}}
+{{- end -}}
diff --git a/deploy/charts/t4-site/templates/deployment.yaml b/deploy/charts/t4-site/templates/deployment.yaml
new file mode 100644
index 00000000..d9693e79
--- /dev/null
+++ b/deploy/charts/t4-site/templates/deployment.yaml
@@ -0,0 +1,91 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: {{ include "t4-site.fullname" . | quote }}
+ labels:
+ {{- include "t4-site.labels" . | nindent 4 }}
+spec:
+ replicas: {{ .Values.replicaCount }}
+ revisionHistoryLimit: 3
+ minReadySeconds: 5
+ strategy:
+ type: RollingUpdate
+ rollingUpdate:
+ maxUnavailable: 0
+ maxSurge: 1
+ selector:
+ matchLabels:
+ {{- include "t4-site.selectorLabels" . | nindent 6 }}
+ template:
+ metadata:
+ {{- with .Values.podAnnotations }}
+ annotations:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ labels:
+ {{- include "t4-site.selectorLabels" . | nindent 8 }}
+ {{- with .Values.podLabels }}
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ spec:
+ automountServiceAccountToken: false
+ terminationGracePeriodSeconds: 30
+ securityContext:
+ runAsNonRoot: true
+ runAsUser: 101
+ runAsGroup: 101
+ fsGroup: 101
+ seccompProfile:
+ type: RuntimeDefault
+ {{- with .Values.imagePullSecrets }}
+ imagePullSecrets:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ containers:
+ - name: site
+ image: {{ include "t4-site.image" . | quote }}
+ imagePullPolicy: {{ .Values.image.pullPolicy | quote }}
+ ports:
+ - name: http
+ containerPort: 8080
+ protocol: TCP
+ startupProbe:
+ httpGet: {path: /healthz, port: http}
+ failureThreshold: 30
+ periodSeconds: 2
+ readinessProbe:
+ httpGet: {path: /healthz, port: http}
+ failureThreshold: 3
+ periodSeconds: 5
+ livenessProbe:
+ httpGet: {path: /healthz, port: http}
+ failureThreshold: 3
+ periodSeconds: 10
+ resources:
+ {{- toYaml .Values.resources | nindent 12 }}
+ securityContext:
+ allowPrivilegeEscalation: false
+ readOnlyRootFilesystem: true
+ runAsNonRoot: true
+ capabilities:
+ drop: [ALL]
+ volumeMounts:
+ - {name: temporary, mountPath: /tmp}
+ - {name: nginx-cache, mountPath: /var/cache/nginx}
+ volumes:
+ - name: temporary
+ emptyDir: {sizeLimit: 16Mi}
+ - name: nginx-cache
+ emptyDir: {sizeLimit: 32Mi}
+ {{- with .Values.nodeSelector }}
+ nodeSelector:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ {{- with .Values.affinity }}
+ affinity:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
+ {{- with .Values.tolerations }}
+ tolerations:
+ {{- toYaml . | nindent 8 }}
+ {{- end }}
diff --git a/deploy/charts/t4-site/templates/ingress.yaml b/deploy/charts/t4-site/templates/ingress.yaml
new file mode 100644
index 00000000..e6d81897
--- /dev/null
+++ b/deploy/charts/t4-site/templates/ingress.yaml
@@ -0,0 +1,22 @@
+{{- if .Values.ingress.enabled }}
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+ name: {{ include "t4-site.fullname" . | quote }}
+ labels:
+ {{- include "t4-site.labels" . | nindent 4 }}
+ {{- with .Values.ingress.annotations }}
+ annotations:
+ {{- toYaml . | nindent 4 }}
+ {{- end }}
+spec:
+ ingressClassName: {{ .Values.ingress.className | quote }}
+ defaultBackend:
+ service:
+ name: {{ include "t4-site.fullname" . | quote }}
+ port:
+ name: http
+ tls:
+ - hosts:
+ - {{ .Values.ingress.hostname | quote }}
+{{- end }}
diff --git a/deploy/charts/t4-site/templates/service.yaml b/deploy/charts/t4-site/templates/service.yaml
new file mode 100644
index 00000000..00b2c7d1
--- /dev/null
+++ b/deploy/charts/t4-site/templates/service.yaml
@@ -0,0 +1,15 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: {{ include "t4-site.fullname" . | quote }}
+ labels:
+ {{- include "t4-site.labels" . | nindent 4 }}
+spec:
+ type: {{ .Values.service.type }}
+ selector:
+ {{- include "t4-site.selectorLabels" . | nindent 4 }}
+ ports:
+ - name: http
+ port: {{ .Values.service.port }}
+ targetPort: http
+ protocol: TCP
diff --git a/deploy/charts/t4-site/values.schema.json b/deploy/charts/t4-site/values.schema.json
new file mode 100644
index 00000000..8f1a0249
--- /dev/null
+++ b/deploy/charts/t4-site/values.schema.json
@@ -0,0 +1,60 @@
+{
+ "$schema": "https://json-schema.org/draft-07/schema#",
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["replicaCount", "image", "imagePullSecrets", "service", "ingress", "resources"],
+ "properties": {
+ "replicaCount": { "type": "integer", "minimum": 1 },
+ "image": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["repository", "tag", "digest", "pullPolicy"],
+ "properties": {
+ "repository": { "type": "string", "minLength": 1 },
+ "tag": { "type": "string" },
+ "digest": { "type": "string", "pattern": "^(|sha256:[0-9a-f]{64})$" },
+ "pullPolicy": { "type": "string", "enum": ["Always", "IfNotPresent", "Never"] }
+ }
+ },
+ "imagePullSecrets": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["name"],
+ "properties": { "name": { "type": "string", "minLength": 1 } }
+ }
+ },
+ "nameOverride": { "type": "string" },
+ "fullnameOverride": { "type": "string" },
+ "service": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["type", "port"],
+ "properties": {
+ "type": { "type": "string", "enum": ["ClusterIP", "NodePort", "LoadBalancer"] },
+ "port": { "type": "integer", "minimum": 1, "maximum": 65535 }
+ }
+ },
+ "ingress": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["enabled", "className", "annotations", "hostname"],
+ "properties": {
+ "enabled": { "type": "boolean" },
+ "className": { "type": "string", "const": "tailscale" },
+ "annotations": {
+ "type": "object",
+ "additionalProperties": { "type": "string" }
+ },
+ "hostname": { "type": "string", "const": "t4-site" }
+ }
+ },
+ "resources": { "type": "object" },
+ "podAnnotations": { "type": "object", "additionalProperties": { "type": "string" } },
+ "podLabels": { "type": "object", "additionalProperties": { "type": "string" } },
+ "nodeSelector": { "type": "object", "additionalProperties": { "type": "string" } },
+ "tolerations": { "type": "array", "items": { "type": "object" } },
+ "affinity": { "type": "object" }
+ }
+}
diff --git a/deploy/charts/t4-site/values.yaml b/deploy/charts/t4-site/values.yaml
new file mode 100644
index 00000000..50c38f61
--- /dev/null
+++ b/deploy/charts/t4-site/values.yaml
@@ -0,0 +1,36 @@
+replicaCount: 2
+
+image:
+ repository: harbor.tailb18de3.ts.net/linkedin-bot/t4-site
+ tag: ""
+ digest: ""
+ pullPolicy: IfNotPresent
+
+imagePullSecrets:
+ - name: harbor-registry-credentials
+nameOverride: ""
+fullnameOverride: ""
+
+service:
+ type: ClusterIP
+ port: 80
+
+ingress:
+ enabled: true
+ className: tailscale
+ annotations: {}
+ hostname: t4-site
+
+resources:
+ requests:
+ cpu: 25m
+ memory: 32Mi
+ limits:
+ cpu: 100m
+ memory: 64Mi
+
+podAnnotations: {}
+podLabels: {}
+nodeSelector: {}
+tolerations: []
+affinity: {}
diff --git a/infra/site/.terraform.lock.hcl b/infra/site/.terraform.lock.hcl
new file mode 100644
index 00000000..0a1f03d6
--- /dev/null
+++ b/infra/site/.terraform.lock.hcl
@@ -0,0 +1,22 @@
+# This file is maintained automatically by "terraform init".
+# Manual edits may be lost in future updates.
+
+provider "registry.terraform.io/hashicorp/google" {
+ version = "6.50.0"
+ constraints = "~> 6.0"
+ hashes = [
+ "h1:faTJQOetP9/RYuHwA3r2SWnuYoyzQNm4tUWZrZggcgY=",
+ "zh:1f3513fcfcbf7ca53d667a168c5067a4dd91a4d4cccd19743e248ff31065503c",
+ "zh:3da7db8fc2c51a77dd958ea8baaa05c29cd7f829bd8941c26e2ea9cb3aadc1e5",
+ "zh:3e09ac3f6ca8111cbb659d38c251771829f4347ab159a12db195e211c76068bb",
+ "zh:7bb9e41c568df15ccf1a8946037355eefb4dfb4e35e3b190808bb7c4abae547d",
+ "zh:81e5d78bdec7778e6d67b5c3544777505db40a826b6eb5abe9b86d4ba396866b",
+ "zh:8d309d020fb321525883f5c4ea864df3d5942b6087f6656d6d8b3a1377f340fc",
+ "zh:93e112559655ab95a523193158f4a4ac0f2bfed7eeaa712010b85ebb551d5071",
+ "zh:d3efe589ffd625b300cef5917c4629513f77e3a7b111c9df65075f76a46a63c7",
+ "zh:d4a4d672bbef756a870d8f32b35925f8ce2ef4f6bbd5b71a3cb764f1b6c85421",
+ "zh:e13a86bca299ba8a118e80d5f84fbdd708fe600ecdceea1a13d4919c068379fe",
+ "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c",
+ "zh:fec30c095647b583a246c39d557704947195a1b7d41f81e369ba377d997faef6",
+ ]
+}
diff --git a/infra/site/cloudformation.yml b/infra/site/cloudformation.yml
deleted file mode 100644
index 94f6240d..00000000
--- a/infra/site/cloudformation.yml
+++ /dev/null
@@ -1,259 +0,0 @@
-AWSTemplateFormatVersion: "2010-09-09"
-Description: T4 Code static site on private S3 with CloudFront
-
-Parameters:
- SiteBucketName:
- Type: String
- Description: Globally unique S3 bucket name for the built site
- CertificateArn:
- Type: String
- Description: ACM certificate ARN in us-east-1 for t4code.net and www.t4code.net
-
-Resources:
- SiteBucket:
- Type: AWS::S3::Bucket
- DeletionPolicy: Retain
- UpdateReplacePolicy: Retain
- Properties:
- BucketName: !Ref SiteBucketName
- BucketEncryption:
- ServerSideEncryptionConfiguration:
- - ServerSideEncryptionByDefault:
- SSEAlgorithm: AES256
- OwnershipControls:
- Rules:
- - ObjectOwnership: BucketOwnerEnforced
- PublicAccessBlockConfiguration:
- BlockPublicAcls: true
- BlockPublicPolicy: true
- IgnorePublicAcls: true
- RestrictPublicBuckets: true
-
- OriginAccessControl:
- Type: AWS::CloudFront::OriginAccessControl
- Properties:
- OriginAccessControlConfig:
- Description: Signed access from the T4 Code distribution to its private S3 bucket
- Name: t4-code-site-oac
- OriginAccessControlOriginType: s3
- SigningBehavior: always
- SigningProtocol: sigv4
-
- UrlRewriteFunction:
- Type: AWS::CloudFront::Function
- Properties:
- AutoPublish: true
- FunctionCode: |
- function handler(event) {
- var request = event.request;
- var uri = request.uri;
- if (uri.endsWith('/')) {
- request.uri += 'index.html';
- } else if (!uri.split('/').pop().includes('.')) {
- request.uri += '/index.html';
- }
- return request;
- }
- FunctionConfig:
- Comment: Resolve extensionless T4 Code site paths to static index files
- Runtime: cloudfront-js-2.0
- Name: t4-code-static-paths
-
- SiteResponseHeadersPolicy:
- Type: AWS::CloudFront::ResponseHeadersPolicy
- Properties:
- ResponseHeadersPolicyConfig:
- Name: t4-code-site-security
- Comment: Security headers for the T4 Code project site
- CustomHeadersConfig:
- Items:
- - Header: Cross-Origin-Opener-Policy
- Override: true
- Value: same-origin
- - Header: Permissions-Policy
- Override: true
- Value: camera=(), geolocation=(), microphone=(), payment=(), usb=()
- SecurityHeadersConfig:
- ContentSecurityPolicy:
- ContentSecurityPolicy: "default-src 'self'; base-uri 'self'; connect-src 'self'; font-src 'self'; form-action 'self'; frame-ancestors 'none'; img-src 'self' data:; object-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; upgrade-insecure-requests"
- Override: true
- ContentTypeOptions:
- Override: true
- FrameOptions:
- FrameOption: DENY
- Override: true
- ReferrerPolicy:
- Override: true
- ReferrerPolicy: strict-origin-when-cross-origin
- StrictTransportSecurity:
- AccessControlMaxAgeSec: 63072000
- IncludeSubdomains: true
- Override: true
- Preload: true
- XSSProtection:
- ModeBlock: true
- Override: true
- Protection: true
-
- DemoResponseHeadersPolicy:
- Type: AWS::CloudFront::ResponseHeadersPolicy
- Properties:
- ResponseHeadersPolicyConfig:
- Name: t4-code-demo-security
- Comment: Security headers for the Flutter web demo
- CustomHeadersConfig:
- Items:
- - Header: Cross-Origin-Opener-Policy
- Override: true
- Value: same-origin
- - Header: Permissions-Policy
- Override: true
- Value: camera=(), geolocation=(), microphone=(), payment=(), usb=()
- SecurityHeadersConfig:
- ContentSecurityPolicy:
- ContentSecurityPolicy: "default-src 'self'; base-uri 'self'; connect-src 'self' https://fonts.gstatic.com; font-src 'self' https://fonts.gstatic.com; form-action 'self'; frame-ancestors 'none'; img-src 'self' data:; object-src 'none'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; upgrade-insecure-requests"
- Override: true
- ContentTypeOptions:
- Override: true
- FrameOptions:
- FrameOption: DENY
- Override: true
- ReferrerPolicy:
- Override: true
- ReferrerPolicy: strict-origin-when-cross-origin
- StrictTransportSecurity:
- AccessControlMaxAgeSec: 63072000
- IncludeSubdomains: true
- Override: true
- Preload: true
- XSSProtection:
- ModeBlock: true
- Override: true
- Protection: true
-
- Distribution:
- Type: AWS::CloudFront::Distribution
- Properties:
- DistributionConfig:
- Aliases:
- - t4code.net
- - www.t4code.net
- Comment: T4 Code public website and docs
- DefaultCacheBehavior:
- AllowedMethods: [GET, HEAD, OPTIONS]
- CachedMethods: [GET, HEAD, OPTIONS]
- CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6
- Compress: true
- FunctionAssociations:
- - EventType: viewer-request
- FunctionARN: !GetAtt UrlRewriteFunction.FunctionARN
- ResponseHeadersPolicyId: !Ref SiteResponseHeadersPolicy
- TargetOriginId: SiteOrigin
- ViewerProtocolPolicy: redirect-to-https
- CacheBehaviors:
- - PathPattern: demo*
- AllowedMethods: [GET, HEAD, OPTIONS]
- CachedMethods: [GET, HEAD, OPTIONS]
- CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6
- Compress: true
- FunctionAssociations:
- - EventType: viewer-request
- FunctionARN: !GetAtt UrlRewriteFunction.FunctionARN
- ResponseHeadersPolicyId: !Ref DemoResponseHeadersPolicy
- TargetOriginId: SiteOrigin
- ViewerProtocolPolicy: redirect-to-https
- DefaultRootObject: index.html
- CustomErrorResponses:
- - ErrorCachingMinTTL: 60
- ErrorCode: 403
- ResponseCode: 404
- ResponsePagePath: /404.html
- - ErrorCachingMinTTL: 60
- ErrorCode: 404
- ResponseCode: 404
- ResponsePagePath: /404.html
- Enabled: true
- HttpVersion: http2and3
- IPV6Enabled: true
- Origins:
- - DomainName: !GetAtt SiteBucket.RegionalDomainName
- Id: SiteOrigin
- OriginAccessControlId: !GetAtt OriginAccessControl.Id
- S3OriginConfig:
- OriginAccessIdentity: ""
- PriceClass: PriceClass_100
- ViewerCertificate:
- AcmCertificateArn: !Ref CertificateArn
- MinimumProtocolVersion: TLSv1.2_2021
- SslSupportMethod: sni-only
-
- SiteBucketPolicy:
- Type: AWS::S3::BucketPolicy
- Properties:
- Bucket: !Ref SiteBucket
- PolicyDocument:
- Version: "2012-10-17"
- Statement:
- - Sid: AllowCloudFrontReadOnly
- Effect: Allow
- Principal:
- Service: cloudfront.amazonaws.com
- Action: s3:GetObject
- Resource: !Sub "${SiteBucket.Arn}/*"
- Condition:
- StringEquals:
- AWS:SourceArn: !Sub "arn:${AWS::Partition}:cloudfront::${AWS::AccountId}:distribution/${Distribution}"
-
- GitHubDeployRole:
- Type: AWS::IAM::Role
- Properties:
- RoleName: t4-code-site-github-deploy
- AssumeRolePolicyDocument:
- Version: "2012-10-17"
- Statement:
- - Effect: Allow
- Principal:
- Federated: !Sub "arn:${AWS::Partition}:iam::${AWS::AccountId}:oidc-provider/token.actions.githubusercontent.com"
- Action: sts:AssumeRoleWithWebIdentity
- Condition:
- StringEquals:
- token.actions.githubusercontent.com:aud: sts.amazonaws.com
- token.actions.githubusercontent.com:sub: repo:LycaonLLC/t4-code:environment:production
- Policies:
- - PolicyName: t4-code-site-deploy
- PolicyDocument:
- Version: "2012-10-17"
- Statement:
- - Sid: ListSiteBucket
- Effect: Allow
- Action:
- - s3:GetBucketLocation
- - s3:ListBucket
- Resource: !GetAtt SiteBucket.Arn
- - Sid: PublishSiteObjects
- Effect: Allow
- Action:
- - s3:DeleteObject
- - s3:GetObject
- - s3:PutObject
- Resource: !Sub "${SiteBucket.Arn}/*"
- - Sid: InvalidateSiteDistribution
- Effect: Allow
- Action:
- - cloudfront:CreateInvalidation
- - cloudfront:GetInvalidation
- Resource: !Sub "arn:${AWS::Partition}:cloudfront::${AWS::AccountId}:distribution/${Distribution}"
-
-Outputs:
- BucketName:
- Description: S3 bucket used for site uploads
- Value: !Ref SiteBucket
- DistributionId:
- Description: CloudFront distribution ID used for invalidations
- Value: !Ref Distribution
- DistributionDomainName:
- Description: CloudFront target for Namecheap ALIAS and CNAME records
- Value: !GetAtt Distribution.DomainName
- GitHubDeployRoleArn:
- Description: GitHub Actions OIDC role for production site deployments
- Value: !GetAtt GitHubDeployRole.Arn
diff --git a/infra/site/edge-startup.sh.tftpl b/infra/site/edge-startup.sh.tftpl
new file mode 100755
index 00000000..83cc513d
--- /dev/null
+++ b/infra/site/edge-startup.sh.tftpl
@@ -0,0 +1,85 @@
+#!/bin/bash
+set -euo pipefail
+
+export DEBIAN_FRONTEND=noninteractive
+apt-get update
+apt-get install -y --no-install-recommends ca-certificates curl jq nginx
+curl -fsSL https://tailscale.com/install.sh | sh
+
+cat >/usr/local/sbin/configure-t4-site-edge <<'SCRIPT'
+#!/bin/bash
+set -euo pipefail
+
+metadata() {
+ curl -fsS -H 'Metadata-Flavor: Google' "http://metadata.google.internal/computeMetadata/v1/$1"
+}
+
+if ! tailscale status --json | jq -e '.BackendState == "Running"' >/dev/null; then
+ access_token="$(metadata instance/service-accounts/default/token | jq -er '.access_token')"
+ oauth_secret="$(
+ curl -fsS \
+ -H "Authorization: Bearer $access_token" \
+ 'https://secretmanager.googleapis.com/v1/projects/${project_id}/secrets/${secret_id}/versions/latest:access' \
+ | jq -er '.payload.data' \
+ | base64 -d
+ )"
+
+ tailscale up \
+ --accept-dns=true \
+ --advertise-tags='${tailscale_tag}' \
+ --auth-key="$oauth_secret" \
+ --hostname=t4-site-edge
+ unset oauth_secret access_token
+fi
+
+tailscale status --json | jq -e '.BackendState == "Running"' >/dev/null
+getent hosts '${origin_hostname}' >/dev/null
+
+cat >/etc/nginx/sites-available/default <<'NGINX'
+server {
+ listen ${edge_port} default_server;
+ listen [::]:${edge_port} default_server;
+ server_name _;
+
+ location / {
+ proxy_pass https://${origin_hostname};
+ proxy_http_version 1.1;
+ proxy_set_header Host ${origin_hostname};
+ proxy_set_header X-Forwarded-Host $host;
+ proxy_set_header X-Forwarded-Proto https;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_ssl_server_name on;
+ proxy_ssl_name ${origin_hostname};
+ proxy_ssl_verify on;
+ proxy_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt;
+ proxy_connect_timeout 5s;
+ proxy_read_timeout 30s;
+ }
+}
+NGINX
+
+nginx -t
+systemctl enable nginx
+systemctl restart nginx
+SCRIPT
+chmod 0750 /usr/local/sbin/configure-t4-site-edge
+
+cat >/etc/systemd/system/t4-site-edge.service <<'UNIT'
+[Unit]
+Description=t4code.com Tailscale edge gateway
+After=network-online.target tailscaled.service
+Wants=network-online.target tailscaled.service
+
+[Service]
+Type=oneshot
+ExecStart=/usr/local/sbin/configure-t4-site-edge
+RemainAfterExit=yes
+Restart=on-failure
+RestartSec=15s
+
+[Install]
+WantedBy=multi-user.target
+UNIT
+
+systemctl daemon-reload
+systemctl enable --now t4-site-edge.service
diff --git a/infra/site/main.tf b/infra/site/main.tf
new file mode 100644
index 00000000..837d2c46
--- /dev/null
+++ b/infra/site/main.tf
@@ -0,0 +1,228 @@
+locals {
+ domain = "t4code.com"
+ dns_zone = "t4code-com"
+ resource_tag = "t4-site"
+}
+
+data "google_dns_managed_zone" "site" {
+ name = local.dns_zone
+ project = var.project_id
+}
+
+resource "google_service_account" "edge" {
+ account_id = "t4-site-edge"
+ display_name = "t4code.com edge gateway"
+ project = var.project_id
+}
+
+resource "google_secret_manager_secret" "tailscale_oauth" {
+ project = var.project_id
+ secret_id = "t4-site-edge-tailscale-oauth-client-secret"
+
+ replication {
+ auto {}
+ }
+}
+
+resource "google_secret_manager_secret_iam_member" "edge_oauth" {
+ project = var.project_id
+ secret_id = google_secret_manager_secret.tailscale_oauth.secret_id
+ role = "roles/secretmanager.secretAccessor"
+ member = "serviceAccount:${google_service_account.edge.email}"
+}
+
+resource "google_compute_network" "edge" {
+ name = "${local.resource_tag}-edge-network"
+ project = var.project_id
+ auto_create_subnetworks = false
+ routing_mode = "REGIONAL"
+}
+
+resource "google_compute_subnetwork" "edge" {
+ name = "${local.resource_tag}-edge-subnet"
+ project = var.project_id
+ region = var.region
+ network = google_compute_network.edge.id
+ ip_cidr_range = "10.64.0.0/28"
+ private_ip_google_access = true
+}
+
+resource "google_compute_address" "edge" {
+ name = "${local.resource_tag}-edge-address"
+ project = var.project_id
+ region = var.region
+ address_type = "EXTERNAL"
+}
+
+resource "google_compute_firewall" "edge_from_google" {
+ name = "${local.resource_tag}-edge-from-google"
+ project = var.project_id
+ network = google_compute_network.edge.id
+ direction = "INGRESS"
+ source_ranges = ["130.211.0.0/22", "35.191.0.0/16"]
+ target_tags = [local.resource_tag]
+
+ allow {
+ protocol = "tcp"
+ ports = [tostring(var.edge_port)]
+ }
+}
+
+resource "google_compute_instance" "edge" {
+ name = "${local.resource_tag}-edge"
+ project = var.project_id
+ zone = var.zone
+ machine_type = var.edge_machine_type
+ tags = [local.resource_tag]
+
+ allow_stopping_for_update = true
+
+ boot_disk {
+ initialize_params {
+ image = "projects/debian-cloud/global/images/family/debian-12"
+ size = 10
+ type = "pd-standard"
+ }
+ }
+
+ network_interface {
+ subnetwork = google_compute_subnetwork.edge.id
+
+ access_config {
+ nat_ip = google_compute_address.edge.address
+ }
+ }
+
+ service_account {
+ email = google_service_account.edge.email
+ scopes = ["cloud-platform"]
+ }
+
+ metadata_startup_script = templatefile("${path.module}/edge-startup.sh.tftpl", {
+ edge_port = var.edge_port
+ origin_hostname = var.origin_hostname
+ project_id = var.project_id
+ secret_id = google_secret_manager_secret.tailscale_oauth.secret_id
+ tailscale_tag = var.tailscale_tag
+ })
+
+ depends_on = [google_secret_manager_secret_iam_member.edge_oauth]
+}
+
+resource "google_compute_instance_group" "edge" {
+ name = "${local.resource_tag}-edge-group-v2"
+ project = var.project_id
+ zone = var.zone
+ network = google_compute_network.edge.id
+ instances = [google_compute_instance.edge.self_link]
+
+ named_port {
+ name = "http"
+ port = var.edge_port
+ }
+ lifecycle {
+ create_before_destroy = true
+ }
+
+}
+
+resource "google_compute_health_check" "site" {
+ name = "${local.resource_tag}-health"
+ project = var.project_id
+ check_interval_sec = 10
+ timeout_sec = 5
+ healthy_threshold = 2
+ unhealthy_threshold = 3
+
+ http_health_check {
+ port = var.edge_port
+ request_path = "/healthz"
+ }
+}
+
+resource "google_compute_backend_service" "site" {
+ name = "${local.resource_tag}-backend"
+ project = var.project_id
+ protocol = "HTTP"
+ port_name = "http"
+ load_balancing_scheme = "EXTERNAL_MANAGED"
+ timeout_sec = 30
+ health_checks = [google_compute_health_check.site.self_link]
+
+ backend {
+ group = google_compute_instance_group.edge.self_link
+ }
+}
+
+resource "google_compute_url_map" "site" {
+ name = "${local.resource_tag}-url-map"
+ project = var.project_id
+ default_service = google_compute_backend_service.site.self_link
+}
+
+resource "google_compute_managed_ssl_certificate" "site" {
+ name = "${local.resource_tag}-certificate"
+ project = var.project_id
+
+ managed {
+ domains = [local.domain]
+ }
+}
+
+resource "google_compute_target_https_proxy" "site" {
+ name = "${local.resource_tag}-https-proxy"
+ project = var.project_id
+ url_map = google_compute_url_map.site.self_link
+ ssl_certificates = [google_compute_managed_ssl_certificate.site.self_link]
+}
+
+resource "google_compute_global_address" "site" {
+ name = "${local.resource_tag}-address"
+ project = var.project_id
+ address_type = "EXTERNAL"
+ ip_version = "IPV4"
+}
+
+resource "google_compute_global_forwarding_rule" "https" {
+ name = "${local.resource_tag}-https"
+ project = var.project_id
+ target = google_compute_target_https_proxy.site.self_link
+ ip_address = google_compute_global_address.site.address
+ port_range = "443"
+ load_balancing_scheme = "EXTERNAL_MANAGED"
+}
+
+resource "google_compute_url_map" "http_redirect" {
+ name = "${local.resource_tag}-http-redirect"
+ project = var.project_id
+
+ default_url_redirect {
+ https_redirect = true
+ redirect_response_code = "MOVED_PERMANENTLY_DEFAULT"
+ strip_query = false
+ }
+}
+
+resource "google_compute_target_http_proxy" "http_redirect" {
+ name = "${local.resource_tag}-http-proxy"
+ project = var.project_id
+ url_map = google_compute_url_map.http_redirect.self_link
+}
+
+resource "google_compute_global_forwarding_rule" "http" {
+ name = "${local.resource_tag}-http"
+ project = var.project_id
+ target = google_compute_target_http_proxy.http_redirect.self_link
+ ip_address = google_compute_global_address.site.address
+ port_range = "80"
+ load_balancing_scheme = "EXTERNAL_MANAGED"
+}
+
+resource "google_dns_record_set" "site" {
+ name = "${local.domain}."
+ project = var.project_id
+ managed_zone = data.google_dns_managed_zone.site.name
+ type = "A"
+ ttl = 300
+ rrdatas = [google_compute_global_address.site.address]
+}
diff --git a/infra/site/outputs.tf b/infra/site/outputs.tf
new file mode 100644
index 00000000..557199f8
--- /dev/null
+++ b/infra/site/outputs.tf
@@ -0,0 +1,44 @@
+output "public_ip_address" {
+ description = "Global IPv4 address serving t4code.com over HTTP and HTTPS."
+ value = google_compute_global_address.site.address
+}
+
+output "public_url" {
+ description = "Canonical HTTPS URL for the production site."
+ value = "https://${local.domain}"
+}
+
+output "cluster_origin_hostname" {
+ description = "Tailnet-only Kubernetes ingress proxied by the edge gateway."
+ value = var.origin_hostname
+}
+
+output "edge_instance_name" {
+ description = "Compute instance proxying Google Front End traffic into the tailnet."
+ value = google_compute_instance.edge.name
+}
+
+output "edge_public_ip_address" {
+ description = "Static egress address used by the edge gateway to join the tailnet."
+ value = google_compute_address.edge.address
+}
+
+output "tailscale_oauth_secret_name" {
+ description = "Secret Manager secret that must contain the edge Tailscale OAuth client secret."
+ value = google_secret_manager_secret.tailscale_oauth.secret_id
+}
+
+output "backend_service_name" {
+ description = "Global backend service forwarding requests to the edge gateway."
+ value = google_compute_backend_service.site.name
+}
+
+output "managed_certificate_name" {
+ description = "Google-managed certificate resource for t4code.com."
+ value = google_compute_managed_ssl_certificate.site.name
+}
+
+output "dns_record_name" {
+ description = "Cloud DNS A record managed by this stack."
+ value = google_dns_record_set.site.name
+}
diff --git a/infra/site/tests/public_edge.tftest.hcl b/infra/site/tests/public_edge.tftest.hcl
new file mode 100644
index 00000000..47a3b227
--- /dev/null
+++ b/infra/site/tests/public_edge.tftest.hcl
@@ -0,0 +1,55 @@
+mock_provider "google" {}
+
+run "default_public_edge_contract" {
+ command = apply
+
+ assert {
+ condition = google_compute_instance.edge.zone == "us-central1-a" && google_compute_instance.edge.machine_type == "e2-micro"
+ error_message = "The edge gateway must use the small, explicitly zoned default instance."
+ }
+
+ assert {
+ condition = google_compute_backend_service.site.protocol == "HTTP" && google_compute_backend_service.site.port_name == "http"
+ error_message = "Google Front Ends must proxy HTTP to the private edge gateway port."
+ }
+
+ assert {
+ condition = !google_compute_network.edge.auto_create_subnetworks && google_compute_subnetwork.edge.ip_cidr_range == "10.64.0.0/28"
+ error_message = "The public gateway must run in an isolated custom subnet."
+ }
+
+ assert {
+ condition = contains(google_compute_firewall.edge_from_google.source_ranges, "130.211.0.0/22") && contains(google_compute_firewall.edge_from_google.source_ranges, "35.191.0.0/16")
+ error_message = "Only documented Google Front End and health-check ranges may reach the gateway."
+ }
+
+ assert {
+ condition = google_secret_manager_secret.tailscale_oauth.secret_id == "t4-site-edge-tailscale-oauth-client-secret" && google_secret_manager_secret_iam_member.edge_oauth.member == "serviceAccount:${google_service_account.edge.email}"
+ error_message = "The gateway alone must be able to read its Tailscale credential."
+ }
+
+ assert {
+ condition = strcontains(google_compute_instance.edge.metadata_startup_script, "t4-site.tailb18de3.ts.net") && strcontains(google_compute_instance.edge.metadata_startup_script, "tag:k8s-shared-ops-ingress-proxy")
+ error_message = "The gateway bootstrap must proxy the private Kubernetes ingress with the shared-ops identity."
+ }
+
+ assert {
+ condition = length(google_compute_managed_ssl_certificate.site.managed[0].domains) == 1 && contains(google_compute_managed_ssl_certificate.site.managed[0].domains, "t4code.com")
+ error_message = "The Google-managed certificate must cover t4code.com."
+ }
+
+ assert {
+ condition = google_compute_url_map.http_redirect.default_url_redirect[0].https_redirect && !google_compute_url_map.http_redirect.default_url_redirect[0].strip_query
+ error_message = "The port 80 URL map must redirect to HTTPS without dropping the query string."
+ }
+
+ assert {
+ condition = google_compute_global_forwarding_rule.http.ip_address == google_compute_global_forwarding_rule.https.ip_address
+ error_message = "HTTP and HTTPS must share the same global address."
+ }
+
+ assert {
+ condition = google_dns_record_set.site.name == "t4code.com." && google_dns_record_set.site.managed_zone == "t4code-com" && google_dns_record_set.site.type == "A"
+ error_message = "Cloud DNS must publish an A record for t4code.com in the existing t4code-com zone."
+ }
+}
diff --git a/infra/site/variables.tf b/infra/site/variables.tf
new file mode 100644
index 00000000..0b2958f7
--- /dev/null
+++ b/infra/site/variables.tf
@@ -0,0 +1,57 @@
+variable "project_id" {
+ description = "Google Cloud project that owns the public edge resources."
+ type = string
+ default = "media-node-636780"
+}
+
+variable "region" {
+ description = "Google Cloud region for the public edge gateway."
+ type = string
+ default = "us-central1"
+}
+
+variable "zone" {
+ description = "Google Cloud zone for the public edge gateway."
+ type = string
+ default = "us-central1-a"
+}
+
+
+variable "edge_machine_type" {
+ description = "Machine type for the single-purpose Tailscale edge gateway."
+ type = string
+ default = "e2-micro"
+}
+
+variable "edge_port" {
+ description = "HTTP port exposed by the edge gateway to Google Front Ends."
+ type = number
+ default = 8080
+
+ validation {
+ condition = var.edge_port >= 1024 && var.edge_port <= 65535
+ error_message = "edge_port must be an unprivileged TCP port between 1024 and 65535."
+ }
+}
+
+variable "origin_hostname" {
+ description = "Tailnet-only Kubernetes ingress hostname proxied by the edge gateway."
+ type = string
+ default = "t4-site.tailb18de3.ts.net"
+
+ validation {
+ condition = can(regex("^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$", var.origin_hostname))
+ error_message = "origin_hostname must be a lowercase fully qualified DNS hostname without a trailing dot."
+ }
+}
+
+variable "tailscale_tag" {
+ description = "Existing tailnet ACL tag assigned to the edge gateway."
+ type = string
+ default = "tag:k8s-shared-ops-ingress-proxy"
+
+ validation {
+ condition = can(regex("^tag:[a-z0-9-]+$", var.tailscale_tag))
+ error_message = "tailscale_tag must be a lowercase Tailscale tag."
+ }
+}
diff --git a/infra/site/versions.tf b/infra/site/versions.tf
new file mode 100644
index 00000000..69388cba
--- /dev/null
+++ b/infra/site/versions.tf
@@ -0,0 +1,19 @@
+terraform {
+ required_version = ">= 1.7.0, < 2.0.0"
+ backend "gcs" {
+ bucket = "roybench-tfstate-672373544179"
+ prefix = "t4code-com/prod"
+ }
+
+
+ required_providers {
+ google = {
+ source = "hashicorp/google"
+ version = "~> 6.0"
+ }
+ }
+}
+
+provider "google" {
+ project = var.project_id
+}
diff --git a/package.json b/package.json
index b3f80664..8b08bd04 100644
--- a/package.json
+++ b/package.json
@@ -21,6 +21,7 @@
"build:flutter:ios": "vp run --filter @t4-code/flutter build:ios",
"build:demo": "node scripts/build-demo.mjs",
"deploy:site": "node scripts/deploy-site.mjs",
+ "test:deploy:site": "node --test scripts/deploy-site.test.mjs scripts/site-pipeline.test.mjs scripts/verify-site-revision.test.mjs",
"deploy:demo": "node scripts/deploy-demo.mjs",
"prepackage": "pnpm check && pnpm build:web && pnpm build:desktop && pnpm build:host && node scripts/package-preflight.mjs",
"package:linux": "pnpm prepackage && node scripts/run-electron-builder.mjs --linux --x64",
diff --git a/scripts/build-site-image.sh b/scripts/build-site-image.sh
new file mode 100755
index 00000000..fb053437
--- /dev/null
+++ b/scripts/build-site-image.sh
@@ -0,0 +1,46 @@
+#!/bin/sh
+set -eu
+
+umask 077
+canonical_build_source_repository=LycaonLLC/t4-code
+authorized_ci_mirror=z-peterson/t4-code
+dockerfile=cluster/images/site/Dockerfile
+
+case "${CI_COMMIT_SHA:-}" in
+ [0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]) ;;
+ *) echo "CI_COMMIT_SHA must be an exact lowercase 40-character SHA" >&2; exit 64 ;;
+esac
+
+: "${BUILDKIT_ADDR:?BUILDKIT_ADDR is required}"
+: "${HARBOR_REGISTRY:?HARBOR_REGISTRY is required}"
+: "${HARBOR_PROJECT:?HARBOR_PROJECT is required}"
+if [ "$HARBOR_REGISTRY" != "harbor.tailb18de3.ts.net" ]; then
+ echo "HARBOR_REGISTRY must be the exact HTTPS tailnet Harbor host" >&2
+ exit 64
+fi
+if [ "${CI_REPO:-}" != "$authorized_ci_mirror" ]; then
+ echo "CI_REPO must identify the authorized Woodpecker mirror" >&2
+ exit 64
+fi
+
+auth_dir=${T4_REGISTRY_AUTH_DIR:-${CI_WORKSPACE:-$PWD}/.cluster-ci/registry-auth}
+test -r "$auth_dir/config.json"
+export DOCKER_CONFIG="$auth_dir"
+test -f "$dockerfile"
+
+repository="$HARBOR_REGISTRY/$HARBOR_PROJECT/quarantine/t4-site"
+reference="$repository:$CI_COMMIT_SHA"
+
+buildctl --addr "$BUILDKIT_ADDR" build \
+ --frontend dockerfile.v0 \
+ --local context=. \
+ --local dockerfile=. \
+ --opt "filename=$dockerfile" \
+ --opt platform=linux/amd64,linux/arm64 \
+ --opt "build-arg:SOURCE_COMMIT=$CI_COMMIT_SHA" \
+ --opt "build-arg:SOURCE_REPOSITORY=https://github.com/$canonical_build_source_repository" \
+ --opt "label:org.opencontainers.image.source=https://github.com/$canonical_build_source_repository" \
+ --opt "label:org.opencontainers.image.revision=$CI_COMMIT_SHA" \
+ --output "type=image,name=$reference,push=true,compression=zstd,force-compression=true,oci-mediatypes=true" \
+ --opt "attest:sbom=" \
+ --opt "attest:provenance=mode=max"
diff --git a/scripts/check-release-consistency.mjs b/scripts/check-release-consistency.mjs
index 7694fc50..eedebd17 100644
--- a/scripts/check-release-consistency.mjs
+++ b/scripts/check-release-consistency.mjs
@@ -28,6 +28,7 @@ export const RELEASE_CONTRACT_PATHS = [
"packages/client/src/omp-client-frames.ts",
"scripts/check-release-publication.mjs",
"scripts/deploy-site.mjs",
+ "scripts/verify-site-revision.mjs",
"scripts/dispatch-site-deployment.mjs",
"scripts/generate-release-manifest.mjs",
"scripts/inspect-linux-update.mjs",
@@ -1086,12 +1087,25 @@ export function collectReleaseConsistencyErrors(files, releaseTag) {
]) {
requireText(manifestGenerator, expected, "scripts/generate-release-manifest.mjs", errors);
}
- requireText(
- files.get("scripts/deploy-site.mjs") ?? "",
- '"apps/site/dist/releases/latest.json"',
- "scripts/deploy-site.mjs",
- errors,
- );
+ const siteDeploy = files.get("scripts/deploy-site.mjs") ?? "";
+ for (const expected of [
+ 'imageRepository: "harbor.tailb18de3.ts.net/linkedin-bot/t4-site"',
+ 'revisionTarget: "origin"',
+ "waitForSiteRevision",
+ "expectedRevision: config.imageTag",
+ ]) {
+ requireText(siteDeploy, expected, "scripts/deploy-site.mjs", errors);
+ }
+ const siteRevisionVerifier = files.get("scripts/verify-site-revision.mjs") ?? "";
+ for (const expected of [
+ 'origin: "https://t4-site.tailb18de3.ts.net/revision.json"',
+ 'public: "https://t4code.com/revision.json"',
+ "readBoundedResponseBytes",
+ "document.revision",
+ 'redirect: "error"',
+ ]) {
+ requireText(siteRevisionVerifier, expected, "scripts/verify-site-revision.mjs", errors);
+ }
const releasePreparation = releaseWorkflow.indexOf("--mode prepare");
const releaseUpload = releaseWorkflow.indexOf("softprops/action-gh-release@");
const releaseRemoteVerification = releaseWorkflow.indexOf("--mode verify");
@@ -1194,6 +1208,7 @@ export function collectReleaseConsistencyErrors(files, releaseTag) {
'[[ "$GITHUB_REF" != "refs/tags/${expected_tag}" ]]',
'[[ "$source_sha" != "$TRUSTED_SHA" ]]',
'git merge-base --is-ancestor "$source_sha" "$TRUSTED_SHA"',
+ 'git merge-base --is-ancestor "$source_sha" refs/remotes/origin/main',
]) {
requireText(
files.get(".github/workflows/deploy-site.yml") ?? "",
diff --git a/scripts/check-release-consistency.test.mjs b/scripts/check-release-consistency.test.mjs
index b049aba9..9a374d93 100644
--- a/scripts/check-release-consistency.test.mjs
+++ b/scripts/check-release-consistency.test.mjs
@@ -320,6 +320,28 @@ test("rejects updater channel, stable manifest, and publication-contract drift",
'WORKFLOW_PATH = ".github/workflows/release.yml"',
),
],
+ [
+ "scripts/verify-site-revision.mjs",
+ (text) =>
+ replaceRequired(
+ text,
+ 'public: "https://t4code.com/revision.json"',
+ 'public: "http://t4code.com/revision.json"',
+ ),
+ ],
+ [
+ "scripts/deploy-site.mjs",
+ (text) => replaceRequired(text, 'revisionTarget: "origin"', 'revisionTarget: "public"'),
+ ],
+ [
+ ".github/workflows/deploy-site.yml",
+ (text) =>
+ replaceRequired(
+ text,
+ 'git merge-base --is-ancestor "$source_sha" refs/remotes/origin/main',
+ 'git merge-base --is-ancestor "$source_sha" refs/remotes/origin/release',
+ ),
+ ],
[
".github/workflows/deploy-site.yml",
(text) =>
diff --git a/scripts/cluster-ci/build-image.sh b/scripts/cluster-ci/build-image.sh
index c143ef0c..a90b995a 100755
--- a/scripts/cluster-ci/build-image.sh
+++ b/scripts/cluster-ci/build-image.sh
@@ -66,8 +66,8 @@ buildctl --addr "$BUILDKIT_ADDR" build \
--opt "label:org.opencontainers.image.source=https://github.com/$canonical_build_source_repository" \
--opt "label:org.opencontainers.image.revision=$CI_COMMIT_SHA" \
--output "type=image,name=$reference,push=true,compression=zstd,force-compression=true,oci-mediatypes=true" \
- --attest type=sbom \
- --attest type=provenance,mode=max \
+ --opt "attest:sbom=" \
+ --opt "attest:provenance=mode=max" \
--metadata-file "$metadata"
digest=$(sed -n 's/.*"containerimage\.digest"[[:space:]]*:[[:space:]]*"\(sha256:[0-9a-f]\{64\}\)".*/\1/p' "$metadata")
diff --git a/scripts/cluster-ci/cluster-ci-contract.test.mjs b/scripts/cluster-ci/cluster-ci-contract.test.mjs
index 184cad23..fb34ef90 100644
--- a/scripts/cluster-ci/cluster-ci-contract.test.mjs
+++ b/scripts/cluster-ci/cluster-ci-contract.test.mjs
@@ -397,6 +397,13 @@ test("Woodpecker keeps upstream gates and serializes bounded cluster publication
);
}
+ const mainOnlySiteSteps = new Set([
+ "harbor-auth-site",
+ "build-site",
+ "promote-site",
+ "deploy-site",
+ "cleanup-site-registry-auth",
+ ]);
for (const [name, step] of Object.entries(steps)) {
assert.match(step.image, /@sha256:[0-9a-f]{64}$/u, `${name} image must be immutable`);
if (step.environment?.HARBOR_REGISTRY) {
@@ -404,7 +411,8 @@ test("Woodpecker keeps upstream gates and serializes bounded cluster publication
}
for (const condition of step.when ?? []) {
if (condition.event === "manual") {
- assert.deepEqual(condition.branch, ["main", "agent/t4-cluster-operator"]);
+ const expectedBranches = mainOnlySiteSteps.has(name) ? "main" : ["main", "agent/t4-cluster-operator"];
+ assert.deepEqual(condition.branch, expectedBranches);
}
}
}
@@ -422,6 +430,9 @@ test("Woodpecker keeps upstream gates and serializes bounded cluster publication
const buildSource = await readFile(resolve(repoRoot, "scripts/cluster-ci/build-image.sh"), "utf8");
assert.match(buildSource, /platform=linux\/amd64,linux\/arm64/u);
+ assert.match(buildSource, /--opt "attest:sbom="/u);
+ assert.match(buildSource, /--opt "attest:provenance=mode=max"/u);
+ assert.doesNotMatch(buildSource, /--attest/u);
assert.match(buildSource, /source_context="https:\/\/github\.com\/\$canonical_build_source_repository\.git#\$CI_COMMIT_SHA"/u);
assert.match(buildSource, /SOURCE_REPOSITORY=https:\/\/github\.com\/\$canonical_build_source_repository/u);
assert.doesNotMatch(buildSource, /https:\/\/github\.com\/z-peterson\/t4-code/u);
@@ -462,7 +473,7 @@ test("Woodpecker keeps upstream gates and serializes bounded cluster publication
);
const promotionGuard = promotionSource.slice(preflightResolveOffset, recursiveCopyOffset);
assert.match(promotionGuard, /if \[ "\$resolved" != "\$digest" \]; then[\s\S]*?exit 65\n fi\n else/u);
- assert.match(promotionGuard, /"failed to resolve digest: \$CI_COMMIT_SHA: not found"/u);
+ assert.match(promotionGuard, /"failed to resolve digest: "\*"\$CI_COMMIT_SHA: not found"/u);
assert.doesNotMatch(promotionSource.slice(0, preflightResolveOffset), /oras copy/u);
const authSource = await readFile(resolve(repoRoot, "scripts/cluster-ci/load-registry-auth.sh"), "utf8");
assert.match(authSource, /chmod 0711 "\$auth_parent" "\$auth_dir"/u);
diff --git a/scripts/cluster-ci/promote-images.sh b/scripts/cluster-ci/promote-images.sh
index fef7a978..28490bb2 100755
--- a/scripts/cluster-ci/promote-images.sh
+++ b/scripts/cluster-ci/promote-images.sh
@@ -41,7 +41,7 @@ do
fi
else
case "$resolved" in
- *"failed to resolve digest: $CI_COMMIT_SHA: not found") ;;
+ *"failed to resolve digest: "*"$CI_COMMIT_SHA: not found") ;;
*)
printf '%s\n' "$resolved" >&2
echo "$component destination commit tag could not be resolved safely" >&2
diff --git a/scripts/deploy-site.mjs b/scripts/deploy-site.mjs
index fe903fd7..2792ebab 100644
--- a/scripts/deploy-site.mjs
+++ b/scripts/deploy-site.mjs
@@ -1,21 +1,47 @@
import { spawnSync } from "node:child_process";
-import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
+import { waitForSiteRevision } from "./verify-site-revision.mjs";
-const BUCKET_PATTERN = /^(?!\d+\.\d+\.\d+\.\d+$)[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/;
-const DISTRIBUTION_PATTERN = /^E[A-Z0-9]{8,20}$/;
+const COMMIT_PATTERN = /^[0-9a-f]{40}$/u;
+const DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/u;
+const FIXED_CONFIG = Object.freeze({
+ namespace: "t4-site",
+ release: "t4-site",
+ chart: "deploy/charts/t4-site",
+ imageRepository: "harbor.tailb18de3.ts.net/linkedin-bot/t4-site",
+ revisionTarget: "origin",
+});
export function resolveDeployConfig(environment = process.env) {
- const bucket = environment.T4_SITE_BUCKET?.trim() ?? "";
- const distributionId = environment.T4_CLOUDFRONT_DISTRIBUTION_ID?.trim() ?? "";
- if (!BUCKET_PATTERN.test(bucket)) {
- throw new Error("T4_SITE_BUCKET must be a valid S3 bucket name");
+ const configuredTag = environment.T4_SITE_IMAGE_TAG?.trim();
+ const ciCommit = environment.CI_COMMIT_SHA?.trim();
+ if (configuredTag && ciCommit && configuredTag !== ciCommit) {
+ throw new Error("T4_SITE_IMAGE_TAG must match CI_COMMIT_SHA when both are set");
}
- if (!DISTRIBUTION_PATTERN.test(distributionId)) {
- throw new Error("T4_CLOUDFRONT_DISTRIBUTION_ID must be a CloudFront distribution ID");
+ const imageTag = configuredTag ?? ciCommit ?? "";
+ if (!COMMIT_PATTERN.test(imageTag)) {
+ throw new Error("T4_SITE_IMAGE_TAG or CI_COMMIT_SHA must be an exact lowercase 40-character commit SHA");
}
- return { bucket, distributionId };
+ const imageDigest = environment.T4_SITE_IMAGE_DIGEST?.trim() ?? "";
+ if (!DIGEST_PATTERN.test(imageDigest)) {
+ throw new Error("T4_SITE_IMAGE_DIGEST must be an exact lowercase sha256 digest");
+ }
+
+ for (const [name, expected] of [
+ ["T4_SITE_NAMESPACE", FIXED_CONFIG.namespace],
+ ["T4_SITE_RELEASE", FIXED_CONFIG.release],
+ ["T4_SITE_CHART", FIXED_CONFIG.chart],
+ ["T4_SITE_IMAGE_REPOSITORY", FIXED_CONFIG.imageRepository],
+ ["T4_SITE_REVISION_TARGET", FIXED_CONFIG.revisionTarget],
+ ]) {
+ const configured = environment[name]?.trim();
+ if (configured && configured !== expected) {
+ throw new Error(`${name} must be ${expected}`);
+ }
+ }
+
+ return { ...FIXED_CONFIG, imageTag, imageDigest };
}
function run(command, args, cwd) {
@@ -26,76 +52,60 @@ function run(command, args, cwd) {
}
}
-export function deploySite(
+export async function deploySite(
config,
repoRoot = resolve(import.meta.dirname, ".."),
runCommand = run,
- releaseVersion = JSON.parse(readFileSync(resolve(repoRoot, "package.json"), "utf8")).version,
+ verifyRevision = waitForSiteRevision,
) {
- const destination = `s3://${config.bucket}`;
- runCommand("pnpm", ["--filter", "@t4-code/site", "build"], repoRoot);
- runCommand(
- "node",
- [
- "scripts/generate-release-manifest.mjs",
- "--version",
- releaseVersion,
- "--output",
- "apps/site/dist/releases/latest.json",
- ],
- repoRoot,
- );
- // Publish new content hashes first and retain old hashes for already-cached entry documents.
- runCommand(
- "aws",
- [
- "s3",
- "sync",
- "apps/site/dist/assets",
- `${destination}/assets`,
- "--cache-control",
- "public,max-age=31536000,immutable",
- "--only-show-errors",
- ],
- repoRoot,
- );
+ const timeout = "10m";
runCommand(
- "aws",
+ "helm",
[
- "s3",
- "sync",
- "apps/site/dist",
- destination,
- "--delete",
- "--exclude",
- "assets/*",
- "--exclude",
- "demo/*",
- "--cache-control",
- "public,max-age=0,must-revalidate",
- "--only-show-errors",
+ "upgrade",
+ "--install",
+ config.release,
+ config.chart,
+ "--namespace",
+ config.namespace,
+ "--create-namespace",
+ "--atomic",
+ "--wait",
+ "--timeout",
+ timeout,
+ "--set-string",
+ `image.repository=${config.imageRepository}`,
+ "--set-string",
+ `image.tag=${config.imageTag}`,
+ "--set-string",
+ `image.digest=${config.imageDigest}`,
],
repoRoot,
);
runCommand(
- "aws",
+ "kubectl",
[
- "cloudfront",
- "create-invalidation",
- "--distribution-id",
- config.distributionId,
- "--paths",
- "/*",
+ "--namespace",
+ config.namespace,
+ "rollout",
+ "status",
+ `deployment/${config.release}`,
+ "--timeout",
+ timeout,
],
repoRoot,
);
+ await verifyRevision({
+ expectedRevision: config.imageTag,
+ target: config.revisionTarget,
+ });
}
const isMain =
process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
if (isMain) {
try {
- deploySite(resolveDeployConfig());
+ await deploySite(resolveDeployConfig());
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
diff --git a/scripts/deploy-site.test.mjs b/scripts/deploy-site.test.mjs
index 3468b020..e9c7a4ad 100644
--- a/scripts/deploy-site.test.mjs
+++ b/scripts/deploy-site.test.mjs
@@ -3,72 +3,97 @@ import test from "node:test";
import { deploySite, resolveDeployConfig } from "./deploy-site.mjs";
-test("site deploy config accepts exact scoped AWS targets", () => {
- assert.deepEqual(
- resolveDeployConfig({
- T4_SITE_BUCKET: "t4code-net-site-595529182031",
- T4_CLOUDFRONT_DISTRIBUTION_ID: "E1ABCDEF234567",
- }),
- {
- bucket: "t4code-net-site-595529182031",
- distributionId: "E1ABCDEF234567",
- },
- );
+const commit = "a".repeat(40);
+const digest = `sha256:${"b".repeat(64)}`;
+const expectedConfig = {
+ namespace: "t4-site",
+ release: "t4-site",
+ chart: "deploy/charts/t4-site",
+ imageRepository: "harbor.tailb18de3.ts.net/linkedin-bot/t4-site",
+ revisionTarget: "origin",
+ imageTag: commit,
+ imageDigest: digest,
+};
+
+test("site deploy config binds production to the exact commit image", () => {
+ assert.deepEqual(resolveDeployConfig({ CI_COMMIT_SHA: commit, T4_SITE_IMAGE_DIGEST: digest }), expectedConfig);
+ assert.deepEqual(resolveDeployConfig({ T4_SITE_IMAGE_TAG: commit, T4_SITE_IMAGE_DIGEST: digest }), expectedConfig);
});
-test("site deploy config rejects missing or malformed targets", () => {
- assert.throws(() => resolveDeployConfig({}), /T4_SITE_BUCKET/);
+test("site deploy config rejects mutable image tags and production target overrides", () => {
+ assert.throws(() => resolveDeployConfig({}), /40-character commit SHA/u);
+ assert.throws(
+ () => resolveDeployConfig({ CI_COMMIT_SHA: commit }),
+ /T4_SITE_IMAGE_DIGEST must be an exact lowercase sha256 digest/u,
+ );
+ assert.throws(
+ () => resolveDeployConfig({ CI_COMMIT_SHA: commit, T4_SITE_IMAGE_DIGEST: "sha256:latest" }),
+ /T4_SITE_IMAGE_DIGEST must be an exact lowercase sha256 digest/u,
+ );
+ assert.throws(() => resolveDeployConfig({ T4_SITE_IMAGE_TAG: "latest" }), /40-character commit SHA/u);
assert.throws(
- () =>
- resolveDeployConfig({
- T4_SITE_BUCKET: "https://example.com",
- T4_CLOUDFRONT_DISTRIBUTION_ID: "E1ABCDEF234567",
- }),
- /T4_SITE_BUCKET/,
+ () => resolveDeployConfig({ T4_SITE_IMAGE_TAG: commit.toUpperCase() }),
+ /40-character commit SHA/u,
);
assert.throws(
- () =>
- resolveDeployConfig({
- T4_SITE_BUCKET: "t4code-net-site-595529182031",
- T4_CLOUDFRONT_DISTRIBUTION_ID: "not-a-distribution",
- }),
- /T4_CLOUDFRONT_DISTRIBUTION_ID/,
+ () => resolveDeployConfig({ T4_SITE_IMAGE_TAG: commit, CI_COMMIT_SHA: "b".repeat(40) }),
+ /must match CI_COMMIT_SHA/u,
+ );
+ assert.throws(
+ () => resolveDeployConfig({ T4_SITE_IMAGE_TAG: commit, T4_SITE_IMAGE_DIGEST: digest, T4_SITE_NAMESPACE: "default" }),
+ /T4_SITE_NAMESPACE must be t4-site/u,
+ );
+ assert.throws(
+ () => resolveDeployConfig({ T4_SITE_IMAGE_TAG: commit, T4_SITE_IMAGE_DIGEST: digest, T4_SITE_REVISION_TARGET: "public" }),
+ /T4_SITE_REVISION_TARGET must be origin/u,
);
});
-test("site deploy uploads immutable assets before switching entry documents", () => {
+test("site deploy atomically applies Helm, confirms rollout, and verifies the exact origin revision", async () => {
const calls = [];
- deploySite(
- { bucket: "t4code-net-site-595529182031", distributionId: "E1ABCDEF234567" },
+ const revisions = [];
+ await deploySite(
+ expectedConfig,
"/repo",
(command, args, cwd) => calls.push({ command, args, cwd }),
- "0.1.17",
+ async (options) => revisions.push(options),
);
- assert.equal(calls.length, 5);
assert.deepEqual(
calls.map(({ command }) => command),
- ["pnpm", "node", "aws", "aws", "aws"],
+ ["helm", "kubectl"],
);
+ assert.deepEqual(calls[0].args, [
+ "upgrade",
+ "--install",
+ "t4-site",
+ "deploy/charts/t4-site",
+ "--namespace",
+ "t4-site",
+ "--create-namespace",
+ "--atomic",
+ "--wait",
+ "--timeout",
+ "10m",
+ "--set-string",
+ "image.repository=harbor.tailb18de3.ts.net/linkedin-bot/t4-site",
+ "--set-string",
+ `image.tag=${commit}`,
+ "--set-string",
+ `image.digest=${digest}`,
+ ]);
assert.deepEqual(calls[1].args, [
- "scripts/generate-release-manifest.mjs",
- "--version",
- "0.1.17",
- "--output",
- "apps/site/dist/releases/latest.json",
+ "--namespace",
+ "t4-site",
+ "rollout",
+ "status",
+ "deployment/t4-site",
+ "--timeout",
+ "10m",
]);
- assert.equal(calls[2].args[2], "apps/site/dist/assets");
- assert.equal(calls[3].args[2], "apps/site/dist");
- assert.equal(calls[2].args.includes("--delete"), false);
- assert.equal(calls[3].args.includes("--delete"), true);
- assert.deepEqual(
- calls[3].args.flatMap((argument, index) =>
- argument === "--exclude" ? [calls[3].args[index + 1]] : [],
- ),
- ["assets/*", "demo/*"],
- );
assert.deepEqual(
calls.map(({ cwd }) => cwd),
- ["/repo", "/repo", "/repo", "/repo", "/repo"],
+ ["/repo", "/repo"],
);
+ assert.deepEqual(revisions, [{ expectedRevision: commit, target: "origin" }]);
});
diff --git a/scripts/promote-site-image.sh b/scripts/promote-site-image.sh
new file mode 100755
index 00000000..1aad14b2
--- /dev/null
+++ b/scripts/promote-site-image.sh
@@ -0,0 +1,44 @@
+#!/bin/sh
+set -eu
+
+umask 077
+: "${CI_COMMIT_SHA:?CI_COMMIT_SHA is required}"
+: "${HARBOR_REGISTRY:?HARBOR_REGISTRY is required}"
+: "${HARBOR_PROJECT:?HARBOR_PROJECT is required}"
+if [ "$HARBOR_REGISTRY" != "harbor.tailb18de3.ts.net" ]; then
+ echo "HARBOR_REGISTRY must be the exact HTTPS tailnet Harbor host" >&2
+ exit 64
+fi
+case "$CI_COMMIT_SHA" in
+ [0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]) ;;
+ *) echo "CI_COMMIT_SHA must be an exact lowercase 40-character SHA" >&2; exit 64 ;;
+esac
+
+auth_dir=${T4_REGISTRY_AUTH_DIR:-${CI_WORKSPACE:-$PWD}/.cluster-ci/registry-auth}
+test -r "$auth_dir/config.json"
+export DOCKER_CONFIG="$auth_dir"
+source="$HARBOR_REGISTRY/$HARBOR_PROJECT/quarantine/t4-site:$CI_COMMIT_SHA"
+destination="$HARBOR_REGISTRY/$HARBOR_PROJECT/t4-site:$CI_COMMIT_SHA"
+digest_file=${T4_SITE_DIGEST_FILE:-.site-ci/site-image-digest}
+source_digest=$(oras resolve --plain-http "$source")
+case "$source_digest" in
+ sha256:????????????????????????????????????????????????????????????????) ;;
+ *) echo "site quarantine image did not resolve to an immutable digest" >&2; exit 65 ;;
+esac
+
+if destination_digest=$(oras resolve --plain-http "$destination" 2>&1); then
+ if [ "$destination_digest" != "$source_digest" ]; then
+ echo "site destination commit tag already resolves to a different digest" >&2
+ exit 65
+ fi
+else
+ case "$destination_digest" in
+ *"failed to resolve digest: "*"$CI_COMMIT_SHA: not found") ;;
+ *) printf '%s\n' "$destination_digest" >&2; exit 65 ;;
+ esac
+ oras copy --from-plain-http --to-plain-http --recursive "$source" "$destination"
+fi
+
+test "$(oras resolve --plain-http "$destination")" = "$source_digest"
+mkdir -p "$(dirname "$digest_file")"
+printf '%s\n' "$source_digest" > "$digest_file"
diff --git a/scripts/site-pipeline.test.mjs b/scripts/site-pipeline.test.mjs
new file mode 100644
index 00000000..aa385588
--- /dev/null
+++ b/scripts/site-pipeline.test.mjs
@@ -0,0 +1,100 @@
+import assert from "node:assert/strict";
+import { spawnSync } from "node:child_process";
+import { readFile } from "node:fs/promises";
+import test from "node:test";
+
+import yaml from "js-yaml";
+
+async function loadYaml(path) {
+ return yaml.load(await readFile(path, "utf8"));
+}
+
+test("GitHub only builds and verifies the exact public revision", async () => {
+ const [source, workflow] = await Promise.all([
+ readFile(".github/workflows/deploy-site.yml", "utf8"),
+ loadYaml(".github/workflows/deploy-site.yml"),
+ ]);
+ assert.deepEqual(workflow.permissions, { contents: "read" });
+ assert.deepEqual(workflow.on.push.branches, ["main", "master"]);
+
+ const job = workflow.jobs["verify-production"];
+ const commands = job.steps.map((step) => step.run ?? "").join("\n");
+ assert.match(commands, /pnpm test:deploy:site/u);
+ assert.match(commands, /pnpm build:site/u);
+ assert.match(commands, /verify-site-revision\.mjs[\s\S]*--target public[\s\S]*--expected "\$SOURCE_SHA"/u);
+ assert.match(commands, /wait-for-release-assets\.mjs/u);
+ assert.match(commands, /git merge-base --is-ancestor "\$source_sha" "\$TRUSTED_SHA"/u);
+ assert.match(source, /steps\.release_state\.outputs\.state == 'not-published'/u);
+ assert.doesNotMatch(source, /kubeconfig|kubectl|\bhelm\b|aws-access-key|aws_secret|amazonaws/iu);
+});
+
+test("Woodpecker has a complete main-only site chain independent of controller publication", async () => {
+ const pipeline = await loadYaml(".woodpecker.yml");
+ const siteSteps = {
+ "harbor-auth-site": ["dependencies"],
+ "build-site": ["harbor-auth-site"],
+ "promote-site": ["build-site"],
+ "deploy-site": ["promote-site"],
+ "cleanup-site-registry-auth": ["deploy-site"],
+ };
+
+ for (const [name, dependencies] of Object.entries(siteSteps)) {
+ const step = pipeline.steps[name];
+ assert.ok(step, `${name} must exist`);
+ assert.deepEqual(step.depends_on, dependencies);
+ assert.equal(step.when[0].event, "push");
+ assert.equal(step.when[0].branch, "main");
+ assert.ok(!JSON.stringify(step.depends_on).includes("controller"));
+ }
+ assert.equal(
+ pipeline.steps["deploy-site"].backend_options.kubernetes.serviceAccountName,
+ "woodpecker-dev-verifier",
+ );
+ assert.equal(pipeline.steps["harbor-auth-site"].environment, undefined);
+ assert.equal(
+ pipeline.steps["build-site"].environment.T4_REGISTRY_AUTH_DIR,
+ undefined,
+ );
+ assert.equal(
+ pipeline.steps["promote-site"].environment.T4_REGISTRY_AUTH_DIR,
+ undefined,
+ );
+ assert.deepEqual(pipeline.steps["cleanup-site-registry-auth"].commands, [
+ "rm -rf .site-ci",
+ ]);
+});
+
+test("site image build accepts only an exact 40-character commit SHA", () => {
+ const run = (commit) =>
+ spawnSync("sh", ["scripts/build-site-image.sh"], {
+ encoding: "utf8",
+ env: { CI_COMMIT_SHA: commit, PATH: process.env.PATH },
+ });
+
+ const valid = run("a".repeat(40));
+ assert.notEqual(valid.status, 64);
+ assert.match(valid.stderr, /BUILDKIT_ADDR.*required/u);
+
+ const truncated = run("a".repeat(39));
+ assert.equal(truncated.status, 64);
+ assert.match(truncated.stderr, /exact lowercase 40-character SHA/u);
+});
+
+test("site image build and promotion use only the immutable Woodpecker commit SHA", async () => {
+ const [build, promote] = await Promise.all([
+ readFile("scripts/build-site-image.sh", "utf8"),
+ readFile("scripts/promote-site-image.sh", "utf8"),
+ ]);
+ assert.match(build, /build-arg:SOURCE_COMMIT=\$CI_COMMIT_SHA/u);
+ assert.match(build, /repository="\$HARBOR_REGISTRY\/\$HARBOR_PROJECT\/quarantine\/t4-site"/u);
+ assert.match(build, /reference="\$repository:\$CI_COMMIT_SHA"/u);
+ assert.match(build, /--opt "attest:sbom="/u);
+ assert.match(build, /--opt "attest:provenance=mode=max"/u);
+ assert.doesNotMatch(build, /--attest/u);
+ assert.match(promote, /quarantine\/t4-site:\$CI_COMMIT_SHA/u);
+ assert.match(promote, /linkedin-bot\/t4-site:\$CI_COMMIT_SHA|\$HARBOR_PROJECT\/t4-site:\$CI_COMMIT_SHA/u);
+ assert.match(promote, /oras resolve --plain-http/u);
+ assert.match(promote, /oras copy --from-plain-http --to-plain-http --recursive/u);
+ assert.match(promote, /"failed to resolve digest: "\*"\$CI_COMMIT_SHA: not found"/u);
+ assert.doesNotMatch(`${build}\n${promote}`, /:latest\b/u);
+});
diff --git a/scripts/verify-site-revision.mjs b/scripts/verify-site-revision.mjs
new file mode 100644
index 00000000..9de7efb3
--- /dev/null
+++ b/scripts/verify-site-revision.mjs
@@ -0,0 +1,165 @@
+import { resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+import { readBoundedResponseBytes } from "./read-bounded-response.mjs";
+
+const COMMIT_PATTERN = /^[0-9a-f]{40}$/u;
+const MAX_RESPONSE_BYTES = 1024;
+
+export const SITE_REVISION_URLS = Object.freeze({
+ origin: "https://t4-site.tailb18de3.ts.net/revision.json",
+ public: "https://t4code.com/revision.json",
+});
+
+export const DEFAULT_REVISION_WAIT = Object.freeze({
+ timeoutMs: 15 * 60 * 1000,
+ intervalMs: 10_000,
+ requestTimeoutMs: 5_000,
+});
+
+function positiveInteger(value, label) {
+ if (!Number.isSafeInteger(value) || value <= 0) {
+ throw new Error(`${label} must be a positive integer`);
+ }
+ return value;
+}
+
+export function requireSourceRevision(revision) {
+ if (typeof revision !== "string" || !COMMIT_PATTERN.test(revision)) {
+ throw new Error("expected revision must be an exact lowercase 40-character commit SHA");
+ }
+ return revision;
+}
+
+export function revisionUrl(target) {
+ if (!Object.hasOwn(SITE_REVISION_URLS, target)) {
+ throw new Error("target must be public or origin");
+ }
+ const url = new URL(SITE_REVISION_URLS[target]);
+ if (url.protocol !== "https:") throw new Error(`${target} revision URL must use HTTPS`);
+ if (target === "public" && url.hostname !== "t4code.com") {
+ throw new Error("public revision URL must use t4code.com");
+ }
+ return url.href;
+}
+
+export async function fetchSiteRevision({
+ target,
+ requestTimeoutMs = DEFAULT_REVISION_WAIT.requestTimeoutMs,
+ fetchImpl = fetch,
+}) {
+ positiveInteger(requestTimeoutMs, "requestTimeoutMs");
+ const url = revisionUrl(target);
+ const response = await fetchImpl(url, {
+ cache: "no-store",
+ headers: { Accept: "application/json" },
+ redirect: "error",
+ signal: AbortSignal.timeout(requestTimeoutMs),
+ });
+ if (response.status !== 200) {
+ await response.body?.cancel?.();
+ throw new Error(`${target} revision endpoint returned HTTP ${response.status}`);
+ }
+ const contentType = response.headers.get("content-type")?.split(";", 1)[0].trim().toLowerCase();
+ if (contentType !== "application/json") {
+ await response.body?.cancel?.();
+ throw new Error(`${target} revision endpoint did not return application/json`);
+ }
+
+ const bytes = await readBoundedResponseBytes(response, {
+ maxBytes: MAX_RESPONSE_BYTES,
+ label: `${target} revision response`,
+ });
+ let document;
+ try {
+ document = JSON.parse(bytes.toString("utf8"));
+ } catch {
+ throw new Error(`${target} revision endpoint returned invalid JSON`);
+ }
+ if (
+ !document ||
+ typeof document !== "object" ||
+ Array.isArray(document) ||
+ Object.keys(document).length !== 1 ||
+ !COMMIT_PATTERN.test(document.revision)
+ ) {
+ throw new Error(`${target} revision endpoint returned an invalid revision document`);
+ }
+ return document.revision;
+}
+
+export async function waitForSiteRevision({
+ expectedRevision,
+ target = "public",
+ timeoutMs = DEFAULT_REVISION_WAIT.timeoutMs,
+ intervalMs = DEFAULT_REVISION_WAIT.intervalMs,
+ requestTimeoutMs = DEFAULT_REVISION_WAIT.requestTimeoutMs,
+ fetchImpl = fetch,
+ now = Date.now,
+ sleep = (milliseconds) => new Promise((resolveSleep) => setTimeout(resolveSleep, milliseconds)),
+ logger = console,
+}) {
+ const expected = requireSourceRevision(expectedRevision);
+ revisionUrl(target);
+ positiveInteger(timeoutMs, "timeoutMs");
+ positiveInteger(intervalMs, "intervalMs");
+ positiveInteger(requestTimeoutMs, "requestTimeoutMs");
+
+ const startedAt = now();
+ let attempts = 0;
+ let lastObservation = "not checked";
+ while (true) {
+ attempts += 1;
+ try {
+ const actual = await fetchSiteRevision({ target, requestTimeoutMs, fetchImpl });
+ lastObservation = `revision ${actual}`;
+ if (actual === expected) {
+ const elapsedMs = now() - startedAt;
+ logger.log(`${target} revision ${expected} verified after ${attempts} check${attempts === 1 ? "" : "s"}.`);
+ return { target, url: revisionUrl(target), revision: actual, attempts, elapsedMs };
+ }
+ } catch (error) {
+ lastObservation = error instanceof Error ? error.message : String(error);
+ }
+
+ const elapsedMs = now() - startedAt;
+ if (elapsedMs >= timeoutMs) {
+ throw new Error(
+ `Timed out after ${elapsedMs} ms waiting for ${target} revision ${expected}; last observation: ${lastObservation}`,
+ );
+ }
+ const delayMs = Math.min(intervalMs, timeoutMs - elapsedMs);
+ logger.log(
+ `${target} has not published revision ${expected} (${lastObservation}); checking again in ${delayMs} ms.`,
+ );
+ await sleep(delayMs);
+ }
+}
+
+function parseArguments(argv) {
+ const options = { target: "public", ...DEFAULT_REVISION_WAIT };
+ for (let index = 0; index < argv.length; index += 2) {
+ const name = argv[index];
+ const value = argv[index + 1];
+ if (!value) throw new Error(`${name ?? "argument"} requires a value`);
+ if (name === "--expected") options.expectedRevision = value;
+ else if (name === "--target") options.target = value;
+ else if (name === "--timeout-ms") options.timeoutMs = Number(value);
+ else if (name === "--interval-ms") options.intervalMs = Number(value);
+ else if (name === "--request-timeout-ms") options.requestTimeoutMs = Number(value);
+ else throw new Error(`unknown argument: ${name}`);
+ }
+ if (!options.expectedRevision) throw new Error("--expected is required");
+ return options;
+}
+
+const isMain =
+ process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
+if (isMain) {
+ try {
+ await waitForSiteRevision(parseArguments(process.argv.slice(2)));
+ } catch (error) {
+ console.error(error instanceof Error ? error.message : String(error));
+ process.exitCode = 1;
+ }
+}
diff --git a/scripts/verify-site-revision.test.mjs b/scripts/verify-site-revision.test.mjs
new file mode 100644
index 00000000..8e5d4d73
--- /dev/null
+++ b/scripts/verify-site-revision.test.mjs
@@ -0,0 +1,113 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ fetchSiteRevision,
+ revisionUrl,
+ waitForSiteRevision,
+} from "./verify-site-revision.mjs";
+
+const expected = "a".repeat(40);
+const stale = "b".repeat(40);
+
+function revisionResponse(revision, options = {}) {
+ return new Response(JSON.stringify({ revision }), {
+ status: options.status ?? 200,
+ headers: { "content-type": options.contentType ?? "application/json" },
+ });
+}
+
+test("public verification is pinned to the HTTPS t4code.com revision endpoint", async () => {
+ assert.equal(revisionUrl("public"), "https://t4code.com/revision.json");
+ assert.equal(revisionUrl("origin"), "https://t4-site.tailb18de3.ts.net/revision.json");
+ assert.throws(() => revisionUrl("https://attacker.example/revision.json"), /public or origin/u);
+
+ const requests = [];
+ const revision = await fetchSiteRevision({
+ target: "public",
+ fetchImpl: async (url, init) => {
+ requests.push({ url, init });
+ return revisionResponse(expected);
+ },
+ });
+ assert.equal(revision, expected);
+ assert.deepEqual(requests.map(({ url }) => url), ["https://t4code.com/revision.json"]);
+ assert.equal(requests[0].init.redirect, "error");
+ assert.equal(requests[0].init.cache, "no-store");
+});
+
+test("waiter retries stale and unavailable revisions until the exact SHA is public", async () => {
+ let clock = 0;
+ const logs = [];
+ const responses = [
+ new Response("unavailable", { status: 503 }),
+ revisionResponse(stale),
+ revisionResponse(expected),
+ ];
+ const result = await waitForSiteRevision({
+ expectedRevision: expected,
+ target: "public",
+ timeoutMs: 50,
+ intervalMs: 10,
+ requestTimeoutMs: 5,
+ fetchImpl: async () => responses.shift(),
+ now: () => clock,
+ sleep: async (milliseconds) => {
+ clock += milliseconds;
+ },
+ logger: { log: (message) => logs.push(message) },
+ });
+
+ assert.deepEqual(result, {
+ target: "public",
+ url: "https://t4code.com/revision.json",
+ revision: expected,
+ attempts: 3,
+ elapsedMs: 20,
+ });
+ assert.equal(logs.length, 3);
+});
+
+test("waiter has a deterministic deadline and reports the last observed revision", async () => {
+ let clock = 0;
+ await assert.rejects(
+ waitForSiteRevision({
+ expectedRevision: expected,
+ target: "origin",
+ timeoutMs: 20,
+ intervalMs: 10,
+ requestTimeoutMs: 5,
+ fetchImpl: async () => revisionResponse(stale),
+ now: () => clock,
+ sleep: async (milliseconds) => {
+ clock += milliseconds;
+ },
+ logger: { log() {} },
+ }),
+ new RegExp(`Timed out after 20 ms.*revision ${stale}`, "u"),
+ );
+});
+
+test("verifier rejects mutable SHAs and malformed revision documents", async () => {
+ await assert.rejects(
+ waitForSiteRevision({ expectedRevision: "main", timeoutMs: 1, intervalMs: 1 }),
+ /40-character commit SHA/u,
+ );
+ await assert.rejects(
+ fetchSiteRevision({
+ target: "public",
+ fetchImpl: async () =>
+ new Response(JSON.stringify({ revision: expected, extra: true }), {
+ headers: { "content-type": "application/json" },
+ }),
+ }),
+ /invalid revision document/u,
+ );
+ await assert.rejects(
+ fetchSiteRevision({
+ target: "public",
+ fetchImpl: async () => revisionResponse(expected, { contentType: "text/plain" }),
+ }),
+ /application\/json/u,
+ );
+});