diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
new file mode 100644
index 0000000..fb2c48b
--- /dev/null
+++ b/.github/CODEOWNERS
@@ -0,0 +1,8 @@
+# Ownership for the Spectro Cloud Palette pack pipeline.
+#
+# Scoped deliberately to the Spectro paths only. This repository has no
+# repository-wide CODEOWNERS, so nothing else changes review routing here.
+# Maintainers should widen or replace this if they want general ownership.
+
+/scripts/spectro/ @matifali @bpmct @greg-the-coder
+/.github/workflows/publish-spectro-pack.yaml @matifali @bpmct @greg-the-coder
diff --git a/.github/workflows/publish-spectro-pack.yaml b/.github/workflows/publish-spectro-pack.yaml
new file mode 100644
index 0000000..9902e34
--- /dev/null
+++ b/.github/workflows/publish-spectro-pack.yaml
@@ -0,0 +1,295 @@
+name: publish-spectro-pack
+
+on:
+ repository_dispatch:
+ types: [coder-release]
+ workflow_dispatch:
+ inputs:
+ coder_version:
+ description: "Coder version"
+ required: true
+ default: "x.x.x"
+ dry_run:
+ description: "Build and validate the pack but do not push a branch or open a PR"
+ type: boolean
+ required: false
+ default: false
+ schedule:
+ # Weekly drift check. The publish job is skipped on this event.
+ - cron: "0 13 * * 1"
+
+permissions: {}
+
+env:
+ # Pinned tool versions. The generator script requires all of these on PATH.
+ HELM_VERSION: "v3.16.3"
+ YQ_VERSION: "v4.44.3"
+ JQ_VERSION: "1.7.1"
+ CRANE_VERSION: "v0.20.2"
+ PYTHON_VERSION: "3.12"
+ PYYAML_VERSION: "6.0.2"
+ PACK_CENTRAL_REPO: "spectrocloud/pack-central"
+ PACK_CENTRAL_BRANCH: "main"
+
+jobs:
+ publish-pack:
+ name: publish-spectro-pack
+ runs-on: ubuntu-latest
+ # Palette packs track stable only. coder/coder sends release_channel in the
+ # coder-release client_payload; manual dispatch is always allowed so a
+ # missed stable release can be backfilled.
+ if: >-
+ github.event.client_payload.release_channel == 'stable'
+ || github.event_name == 'workflow_dispatch'
+ permissions:
+ contents: "read"
+
+ steps:
+ - name: Resolve version
+ id: version
+ env:
+ DISPATCH_VERSION: ${{ github.event.client_payload.coder_version }}
+ INPUT_VERSION: ${{ github.event.inputs.coder_version }}
+ run: |
+ set -euo pipefail
+ if [[ "${GITHUB_EVENT_NAME}" == "repository_dispatch" ]]; then
+ unsafe_coder_version="${DISPATCH_VERSION}"
+ else
+ unsafe_coder_version="${INPUT_VERSION}"
+ fi
+ # The generator takes a bare semver, so drop any leading v.
+ coder_version="${unsafe_coder_version#v}"
+ if [[ ! "${coder_version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+ echo "Unusable version: ${unsafe_coder_version}"
+ exit 1
+ fi
+ echo "coder_version=${coder_version}" >> "$GITHUB_OUTPUT"
+ echo "branch=auto-release/coder-pack-${coder_version}" >> "$GITHUB_OUTPUT"
+
+ - name: Checkout Repository
+ uses: actions/checkout@v4
+ with:
+ path: packages
+ persist-credentials: false
+
+ - name: Checkout pack-central
+ uses: actions/checkout@v4
+ with:
+ repository: ${{ env.PACK_CENTRAL_REPO }}
+ ref: ${{ env.PACK_CENTRAL_BRANCH }}
+ path: pack-central
+ token: ${{ secrets.CDRCI_GITHUB_TOKEN }}
+ persist-credentials: false
+
+ - name: Check for existing work
+ id: guard
+ env:
+ GH_TOKEN: ${{ secrets.CDRCI_GITHUB_TOKEN }}
+ FORK_OWNER: ${{ vars.SPECTRO_FORK_OWNER || 'cdrci' }}
+ BRANCH: ${{ steps.version.outputs.branch }}
+ VERSION: ${{ steps.version.outputs.coder_version }}
+ run: |
+ set -euo pipefail
+ # Bail out before doing any work if the pack was already submitted.
+ open_prs="$(gh api -X GET "repos/${PACK_CENTRAL_REPO}/pulls" \
+ -f state=open -f "head=${FORK_OWNER}:${BRANCH}" --jq 'length')"
+ if [ "${open_prs}" -gt 0 ]; then
+ echo "Bailing out as PR already exists for ${FORK_OWNER}:${BRANCH}"
+ echo "skip=true" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+ if gh api "repos/${PACK_CENTRAL_REPO}/contents/packs/coder-${VERSION}?ref=${PACK_CENTRAL_BRANCH}" >/dev/null 2>&1; then
+ echo "Bailing out as packs/coder-${VERSION} already exists on ${PACK_CENTRAL_BRANCH}"
+ echo "skip=true" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+ echo "skip=false" >> "$GITHUB_OUTPUT"
+
+ - name: Setup `helm`
+ if: steps.guard.outputs.skip != 'true'
+ uses: azure/setup-helm@v4
+ with:
+ version: ${{ env.HELM_VERSION }}
+
+ - name: Setup `python`
+ if: steps.guard.outputs.skip != 'true'
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ env.PYTHON_VERSION }}
+
+ - name: Install `PyYAML`
+ if: steps.guard.outputs.skip != 'true'
+ run: python3 -m pip install --no-input "PyYAML==${PYYAML_VERSION}"
+
+ - name: Install `yq`, `jq` and `crane`
+ if: steps.guard.outputs.skip != 'true'
+ run: |
+ set -euxo pipefail
+ # No org-standard setup actions exist for these, so the download URLs
+ # are pinned by version instead.
+ mkdir -p "${RUNNER_TEMP}/bin"
+ curl -fsSL -o "${RUNNER_TEMP}/bin/yq" \
+ "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64"
+ curl -fsSL -o "${RUNNER_TEMP}/bin/jq" \
+ "https://github.com/jqlang/jq/releases/download/jq-${JQ_VERSION}/jq-linux-amd64"
+ curl -fsSL "https://github.com/google/go-containerregistry/releases/download/${CRANE_VERSION}/go-containerregistry_Linux_x86_64.tar.gz" \
+ | tar -xz -C "${RUNNER_TEMP}/bin" crane
+ chmod +x "${RUNNER_TEMP}/bin/yq" "${RUNNER_TEMP}/bin/jq" "${RUNNER_TEMP}/bin/crane"
+ echo "${RUNNER_TEMP}/bin" >> "$GITHUB_PATH"
+
+ - name: Build Spectro pack
+ if: steps.guard.outputs.skip != 'true'
+ working-directory: packages
+ env:
+ # The generator lifts logo.png and the validator scripts out of a
+ # pack-central checkout rather than vendoring them.
+ PACK_CENTRAL_DIR: ${{ github.workspace }}/pack-central
+ VERSION: ${{ steps.version.outputs.coder_version }}
+ OUTDIR: ${{ github.workspace }}/build/spectro
+ run: |
+ set -euxo pipefail
+ # The script performs chart digest verification, values structure
+ # checks, a helm template render and a crane manifest lookup, and
+ # exits non-zero if any of them fail.
+ ./scripts/spectro/build-pack.sh "${VERSION}" --outdir "${OUTDIR}"
+
+ - name: Upload pack artifact (if dry-run)
+ if: steps.guard.outputs.skip != 'true' && inputs.dry_run
+ uses: actions/upload-artifact@v4
+ with:
+ name: spectro-pack-coder-${{ steps.version.outputs.coder_version }}
+ path: ${{ github.workspace }}/build/spectro/packs/coder-${{ steps.version.outputs.coder_version }}
+ retention-days: 7
+
+ - name: Submit pack to pack-central
+ id: submit
+ if: steps.guard.outputs.skip != 'true' && !inputs.dry_run
+ env:
+ GH_TOKEN: ${{ secrets.CDRCI_GITHUB_TOKEN }}
+ FORK_OWNER: ${{ vars.SPECTRO_FORK_OWNER || 'cdrci' }}
+ BRANCH: ${{ steps.version.outputs.branch }}
+ VERSION: ${{ steps.version.outputs.coder_version }}
+ OUTDIR: ${{ github.workspace }}/build/spectro
+ run: |
+ set -euxo pipefail
+
+ # Setup Git
+ git config --global user.email "ci@coder.com"
+ git config --global user.name "Coder CI"
+ git config --global credential.helper "store"
+
+ # The fork can be many commits behind after weeks of no releases, so
+ # refresh it from upstream before branching.
+ gh repo sync "${FORK_OWNER}/pack-central" -b "${PACK_CENTRAL_BRANCH}"
+
+ # Set up cdrci credentials for pushing to the fork.
+ echo "https://x-access-token:${GH_TOKEN}@github.com" >> ~/.git-credentials
+
+ temp_dir="$(mktemp -d)"
+ git clone "https://github.com/${FORK_OWNER}/pack-central" "${temp_dir}/pack-central"
+ cd "${temp_dir}/pack-central"
+ git checkout -b "${BRANCH}"
+
+ cp -R "${OUTDIR}/packs/coder-${VERSION}" "packs/coder-${VERSION}"
+ git add "packs/coder-${VERSION}"
+ git commit -m "Upgrade coder pack to ${VERSION}"
+ git push -u origin -f "${BRANCH}"
+
+ # pack-summary.md is written by the generator and holds the upstream
+ # chart source and its verified sha256 digest.
+ {
+ echo "Automated pack update generated by the publish-spectro-pack workflow in coder/packages."
+ echo
+ cat "${OUTDIR}/pack-summary.md"
+ echo
+ echo "Offline validation that passed before this PR was opened:"
+ echo
+ echo "- chart digest verified against the helm.coder.com index.yaml entry"
+ echo "- validator/check-values-structure.py from this repository"
+ echo "- helm template render assertion on the packaged chart"
+ echo "- crane manifest lookup on the referenced image"
+ echo
+ echo "Palette tenant validation is not wired up yet, so this PR has not been imported into a tenant registry."
+ } > "${temp_dir}/pr-body.md"
+
+ pr_url="$(gh pr create \
+ --repo "${PACK_CENTRAL_REPO}" \
+ -B "${PACK_CENTRAL_BRANCH}" -H "${FORK_OWNER}:${BRANCH}" \
+ -t "Upgrade coder pack to ${VERSION}" \
+ -F "${temp_dir}/pr-body.md")"
+ echo "pr_url=${pr_url}" >> "$GITHUB_OUTPUT"
+
+ - name: Comment on PR
+ if: steps.submit.outcome == 'success' && steps.submit.outputs.pr_url != ''
+ env:
+ GH_TOKEN: ${{ secrets.CDRCI_GITHUB_TOKEN }}
+ PR_URL: ${{ steps.submit.outputs.pr_url }}
+ REVIEWERS: ${{ vars.SPECTRO_PACK_REVIEWERS }}
+ run: |
+ set -euo pipefail
+ if [ -z "${REVIEWERS}" ]; then
+ echo "SPECTRO_PACK_REVIEWERS is unset, skipping cc comment"
+ exit 0
+ fi
+ gh pr comment "${PR_URL}" --body "cc: ${REVIEWERS}"
+
+ drift-check:
+ name: spectro-pack-drift-check
+ runs-on: ubuntu-latest
+ if: github.event_name == 'schedule'
+ permissions:
+ contents: "read"
+ issues: "write"
+
+ steps:
+ - name: Compare stable release with published pack
+ id: drift
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ set -euo pipefail
+ # releases/latest returns the release flagged Latest, which for
+ # coder/coder is the stable release and not the newest tag. Reading
+ # tags instead would report mainline versions as drift.
+ stable="$(gh api repos/coder/coder/releases/latest --jq .tag_name)"
+ stable="${stable#v}"
+
+ published="$(gh api "repos/${PACK_CENTRAL_REPO}/contents/packs?ref=${PACK_CENTRAL_BRANCH}" \
+ --jq '.[] | select(.type == "dir") | .name' \
+ | grep '^coder-' | sed 's/^coder-//' | sort -V | tail -n1)"
+
+ echo "stable=${stable}" >> "$GITHUB_OUTPUT"
+ echo "published=${published:-none}" >> "$GITHUB_OUTPUT"
+
+ highest="$(printf '%s\n%s\n' "${stable}" "${published:-0.0.0}" | sort -V | tail -n1)"
+ if [ "${published:-0.0.0}" = "${stable}" ] || [ "${highest}" = "${published:-0.0.0}" ]; then
+ echo "behind=false" >> "$GITHUB_OUTPUT"
+ else
+ echo "behind=true" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Open or update tracking issue
+ if: steps.drift.outputs.behind == 'true'
+ env:
+ GH_TOKEN: ${{ github.token }}
+ STABLE: ${{ steps.drift.outputs.stable }}
+ PUBLISHED: ${{ steps.drift.outputs.published }}
+ run: |
+ set -euo pipefail
+ title="Spectro Cloud pack is behind the stable Coder release"
+ body="$(printf '%s\n\n%s\n\n%s\n' \
+ "Stable Coder release: ${STABLE}" \
+ "Highest pack on ${PACK_CENTRAL_REPO} ${PACK_CENTRAL_BRANCH}: ${PUBLISHED}" \
+ "Run the publish-spectro-pack workflow manually with coder_version ${STABLE} to backfill, then investigate why the coder-release dispatch did not produce a PR.")"
+
+ # One rolling issue per drift condition, refreshed with a comment so
+ # the weekly run does not create duplicates.
+ existing="$(gh issue list --repo "${GITHUB_REPOSITORY}" --state open \
+ --search "\"${title}\" in:title" --json number,title \
+ --jq "[.[] | select(.title == \"${title}\")] | .[0].number // empty")"
+
+ if [ -n "${existing}" ]; then
+ gh issue comment "${existing}" --repo "${GITHUB_REPOSITORY}" --body "${body}"
+ else
+ gh issue create --repo "${GITHUB_REPOSITORY}" --title "${title}" --body "${body}"
+ fi
diff --git a/.gitignore b/.gitignore
index b50d0c2..551464c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,6 @@
packer-manifest.json
# built snaps
*.snap
+
+# generated Spectro Cloud pack output
+build/
diff --git a/README.md b/README.md
index 278e0ae..9ee7b91 100644
--- a/README.md
+++ b/README.md
@@ -12,3 +12,4 @@ Install [Coder](https://github.com/coder/coder) to your preferred cloud with a p
|
| AWS EKS | In progress | [Docs: Coder on Kubernetes](https://coder.com/docs/v2/latest/install/kubernetes) | [Deploy from AWS Marketplace](https://example.com) |
|
| Google Compute Engine | Live ✅ | [Guide: Google Compute Engine](https://coder.com/docs/v2/latest/platforms/gcp) | [Deploy from GCP Marketplace](https://console.cloud.google.com/marketplace/product/coder-enterprise-market-public/coder-v2) |
|
| Snapcraft | Live ✅ | `snap install coder` | [](https://snapcraft.io/coder) |
+| Spectro Cloud | Spectro Cloud Palette | Live ✅ | [Docs: Coder on Kubernetes](https://coder.com/docs/install/kubernetes) | [Community pack in pack-central](https://github.com/spectrocloud/pack-central/tree/main/packs) |
diff --git a/scripts/spectro/README.md b/scripts/spectro/README.md
new file mode 100644
index 0000000..7e8e451
--- /dev/null
+++ b/scripts/spectro/README.md
@@ -0,0 +1,106 @@
+# Spectro Cloud Palette pack pipeline
+
+Keeps the Coder community pack in
+[spectrocloud/pack-central](https://github.com/spectrocloud/pack-central) in
+step with the stable Coder release.
+
+## Why this exists
+
+Palette community packs are maintained by the contributing vendor, not by
+Spectro Cloud. Nothing about merging a pack creates an obligation on their side
+to update it, and Palette deprecates a pack minor once two newer minors exist,
+then disables it after three months and deletes it three months after that. A
+pack that nobody republishes ages out of Palette on its own.
+
+The first Coder pack was assembled by hand and drifted accordingly. It was
+published as `coder-2.23.3` but shipped upstream chart content from 2.21.3,
+because `Chart.yaml` was version-bumped without re-vendoring the chart body. Its
+`values.yaml` also nested the chart values one level too shallow, so every
+override a Palette user set was silently discarded and the deployment always ran
+chart defaults.
+
+Both defects came from hand edits, and both are the kind of thing a generator
+plus an assertion catches for free. Hence this directory.
+
+## Contents
+
+| Path | Purpose |
+| --- | --- |
+| `build-pack.sh` | Builds `packs/coder-/` from the published Helm chart and validates it. |
+| `pack-README.md` | Template for the pack's own README, required per pack version by pack-central. `__VERSION__` is substituted at build time. |
+
+The workflow that drives this on a release is
+`.github/workflows/publish-spectro-pack.yaml`.
+
+## Running it locally
+
+```sh
+git clone https://github.com/spectrocloud/pack-central.git /tmp/pack-central
+
+PACK_CENTRAL_DIR=/tmp/pack-central \
+ ./scripts/spectro/build-pack.sh 2.35.6
+```
+
+Output lands in `./build/spectro/packs/coder-2.35.6/` with a provenance summary
+at `./build/spectro/pack-summary.md`.
+
+Requires `helm`, `yq`, `jq`, `crane`, and `python3` with PyYAML.
+
+## What it guarantees
+
+The script refuses to produce a pack unless all of the following hold.
+
+- The chart tarball's sha256 matches the digest published in
+ `https://helm.coder.com/v2/index.yaml`.
+- The chart body's own `version` and `appVersion` equal the requested version,
+ and the repackaged archive agrees with its own filename. This is the check
+ that the 2.23.3 pack would have failed.
+- `pack.json` still carries `name: coder-chart` and `displayName: Coder`.
+ Changing either makes Palette treat the result as a brand new pack rather than
+ a new version of the existing one.
+- `charts.coder` in the pack values mirrors the chart's root keys exactly, and
+ pack-central's own `validator/check-values-structure.py` exits 0.
+- Rendering the chart with only the pack's `charts.coder` subtree produces
+ `ghcr.io/coder/coder:v`, and an override probe injected into that
+ subtree reaches the rendered manifests. A pack with inert values renders
+ identically either way, which is exactly how the previous breakage went
+ unnoticed.
+- Every image in `pack.content.images` resolves with `crane manifest`.
+- Every chart value path documented in the pack README still exists in the
+ chart, so the parameter table cannot quietly start lying after an upstream
+ rename.
+
+## Release channel
+
+Only stable releases produce a pack. `coder/coder` sends `release_channel` in
+the `coder-release` dispatch payload, and the workflow gates on it. Stable is
+roughly one release a month, which stays comfortably ahead of Palette's
+deprecation clock without adding noise to a third-party repository whose review
+queue is measured in weeks.
+
+## Not yet covered
+
+pack-central asks contributors to push the pack to their own registry and test
+it in a Palette environment before opening a pull request. That step needs a
+Palette tenant. Coder is working with Spectro Cloud on a partner tenant; once it
+exists, the pipeline should push the pack to an OCI registry under
+`spectro-packs/archive/`, register and sync it through
+`POST /v1/registries/oci/basic`, and run `POST /v1/clusterprofiles/validate/packs`,
+which validates a profile without deploying a cluster. Note that Palette
+requires ORAS v1.0.0 specifically for pack pushes.
+
+Until then the pull request states which offline checks ran.
+
+## Maintenance
+
+The pack README parameter table is hand-maintained. `build-pack.sh` verifies
+that every path it documents still exists, but it cannot tell whether a
+newly added chart value deserves a row. Review `pack-README.md` when upstream
+adds a significant option.
+
+The workflow also runs a weekly drift check that compares
+`GET /repos/coder/coder/releases/latest`, which returns the stable release
+rather than the newest tag, against the highest `packs/coder-*` directory on
+pack-central. If the pack falls behind it opens a tracking issue here. That
+guard exists because silent rot is the failure mode that produced the original
+problem.
diff --git a/scripts/spectro/build-pack.sh b/scripts/spectro/build-pack.sh
new file mode 100755
index 0000000..f329c0a
--- /dev/null
+++ b/scripts/spectro/build-pack.sh
@@ -0,0 +1,423 @@
+#!/usr/bin/env bash
+
+# Builds a Spectro Cloud Palette community pack for a given Coder version.
+#
+# The pack is a re-vendored copy of the upstream Coder Helm chart plus the
+# Palette-specific metadata that spectrocloud/pack-central requires. Output is
+# a directory tree that can be committed verbatim to pack-central under
+# packs/coder-/.
+#
+# Every artifact is derived from the published chart. Nothing is hand-edited,
+# because the two defects in the previously contributed pack both came from
+# hand edits: Chart.yaml was version-bumped without re-vendoring the chart
+# body, and the pack values.yaml was nested one level too shallow, which made
+# every user override silently inert.
+
+set -euo pipefail
+
+readonly CHART_REPO="https://helm.coder.com/v2"
+readonly IMAGE_REPO="ghcr.io/coder/coder"
+
+# Palette treats a pack with a different name or displayName as an entirely new
+# pack rather than a new version of an existing one. These two values must stay
+# byte-stable across every release, forever.
+readonly PACK_NAME="coder-chart"
+readonly PACK_DISPLAY_NAME="Coder"
+
+SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
+readonly SCRIPT_DIR
+
+log() { printf ' %s\n' "$*" >&2; }
+step() { printf '\n==> %s\n' "$*" >&2; }
+die() { printf '\nERROR: %s\n' "$*" >&2; exit 1; }
+
+usage() {
+ cat >&2 <<'EOF'
+Usage: build-pack.sh [--outdir ] [--pack-central ]
+
+Arguments:
+ Bare semver of the Coder release, with no leading "v".
+ Example: 2.35.6
+
+Options:
+ --outdir Where to write the pack. Default: ./build/spectro
+ --pack-central
+ Checkout of spectrocloud/pack-central. Supplies logo.png
+ and the previous pack.json, and provides the upstream
+ validator used for the pre-flight structural check.
+ Defaults to $PACK_CENTRAL_DIR.
+
+Requires: helm, yq, jq, crane, python3 with PyYAML.
+EOF
+ exit 2
+}
+
+version=""
+outdir="./build/spectro"
+pack_central="${PACK_CENTRAL_DIR:-}"
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --outdir)
+ [[ $# -ge 2 ]] || usage
+ outdir="$2"
+ shift 2
+ ;;
+ --pack-central)
+ [[ $# -ge 2 ]] || usage
+ pack_central="$2"
+ shift 2
+ ;;
+ -h | --help) usage ;;
+ -*) die "unknown option: $1" ;;
+ *)
+ [[ -z "$version" ]] || die "unexpected argument: $1"
+ version="$1"
+ shift
+ ;;
+ esac
+done
+
+[[ -n "$version" ]] || usage
+
+step "Checking inputs and tooling"
+
+# pack-central's validate_pack_version rejects a leading "v" outright. The
+# container image tag, confusingly, does carry one. Catch the mixup here rather
+# than in someone else's CI.
+if [[ "$version" == v* ]]; then
+ die "version must not have a leading 'v' (got '$version'). The pack version is bare semver; only the image tag is prefixed."
+fi
+if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+ die "version must be bare semver x.y.z (got '$version')"
+fi
+
+for tool in helm yq jq crane python3 tar curl sha256sum; do
+ command -v "$tool" >/dev/null 2>&1 || die "required tool not found on PATH: $tool"
+done
+python3 -c 'import yaml' 2>/dev/null || die "python3 is missing the PyYAML module"
+
+[[ -n "$pack_central" ]] || die "pass --pack-central or set PACK_CENTRAL_DIR to a checkout of spectrocloud/pack-central"
+[[ -d "$pack_central/packs" ]] || die "not a pack-central checkout: $pack_central"
+
+# The newest Coder pack already in pack-central is the source of truth for the
+# things that must not drift: the logo bytes and the pack.json identity fields.
+# The target version is excluded so that re-running against a branch which
+# already contains it cannot seed the new pack from itself.
+prev_pack="$(find "$pack_central/packs" -maxdepth 1 -type d -name 'coder-*' -print |
+ grep -v "/coder-$version\$" |
+ sort -V | tail -n 1)"
+[[ -n "$prev_pack" ]] || die "no existing packs/coder-* directory found in $pack_central"
+log "tooling ok, previous pack: ${prev_pack#"$pack_central"/}"
+
+pack_dir="$outdir/packs/coder-$version"
+if [[ -e "$pack_dir" ]]; then
+ die "$pack_dir already exists; remove it or choose another --outdir"
+fi
+
+work="$(mktemp -d)"
+trap 'rm -rf "$work"' EXIT
+
+step "Fetching the published chart index"
+
+curl -fsSL "$CHART_REPO/index.yaml" -o "$work/index.yaml" ||
+ die "could not fetch $CHART_REPO/index.yaml"
+
+expected_digest="$(yq -r \
+ ".entries.coder[] | select(.version == \"$version\") | .digest" \
+ "$work/index.yaml" | head -n 1)"
+
+if [[ -z "$expected_digest" || "$expected_digest" == "null" ]]; then
+ die "chart version $version is not published at $CHART_REPO. Available recent versions: $(yq -r '[.entries.coder[].version] | .[0:5] | join(", ")' "$work/index.yaml")"
+fi
+log "index digest: $expected_digest"
+
+step "Downloading and verifying the chart"
+
+tarball="$work/coder_helm_$version.tgz"
+curl -fsSL "$CHART_REPO/coder_helm_$version.tgz" -o "$tarball" ||
+ die "could not download coder_helm_$version.tgz"
+
+actual_digest="$(sha256sum "$tarball" | cut -d' ' -f1)"
+if [[ "$actual_digest" != "$expected_digest" ]]; then
+ die "chart digest mismatch. index.yaml says $expected_digest, downloaded artifact is $actual_digest"
+fi
+log "sha256 verified against the published index"
+
+step "Unpacking and applying the Palette chart patch"
+
+mkdir -p "$pack_dir/charts"
+tar -xzf "$tarball" -C "$pack_dir/charts"
+[[ -d "$pack_dir/charts/coder" ]] || die "chart tarball did not contain a top-level coder/ directory"
+
+chart_yaml="$pack_dir/charts/coder/Chart.yaml"
+
+# The library chart is vendored inside the packaged tarball at charts/libcoder,
+# so the dependency path has to lose the parent traversal that upstream uses in
+# its source tree. The icon is rewritten to the Palette convention, and
+# kubeVersion is normalised to the form the Palette parser accepts. These three
+# edits, plus dropping the upstream chart README, are the complete set of
+# Spectro-specific deltas.
+#
+# These are line-oriented rather than yq edits on purpose. yq reflows sequence
+# indentation, which would make every future pack diff noisy against the
+# previous version directory for no semantic gain.
+sed -i \
+ -e 's|^\(\s*repository:\s*\)file://\.\./libcoder\s*$|\1file://libcoder|' \
+ -e 's|^icon:.*$|icon: file://assets/icons/coder.png|' \
+ -e "s|^kubeVersion:.*\$|kubeVersion: '>=1.19-0'|" \
+ "$chart_yaml"
+
+# Chart.lock is deliberately left as upstream published it. Its digest is
+# computed over the dependency list, so rewriting the repository field without
+# recomputing the digest would leave the lock internally inconsistent. Nothing
+# in the pack path resolves dependencies, because libcoder ships pre-vendored.
+
+rm -f "$pack_dir/charts/coder/README.md"
+
+# Assert the patch landed, since sed fails silently when a pattern stops matching.
+[[ "$(yq -r '.dependencies[] | select(.name == "libcoder") | .repository' "$chart_yaml")" == "file://libcoder" ]] ||
+ die "libcoder dependency repository was not rewritten in Chart.yaml"
+[[ "$(yq -r '.icon' "$chart_yaml")" == "file://assets/icons/coder.png" ]] ||
+ die "icon was not rewritten in Chart.yaml"
+[[ "$(yq -r '.kubeVersion' "$chart_yaml")" == ">=1.19-0" ]] ||
+ die "kubeVersion was not rewritten in Chart.yaml"
+[[ -d "$pack_dir/charts/coder/charts/libcoder" ]] ||
+ die "libcoder is not vendored inside the chart; the dependency path rewrite would break packaging"
+log "applied the three Palette Chart.yaml edits"
+
+chart_version="$(yq -r '.version' "$chart_yaml")"
+chart_app_version="$(yq -r '.appVersion' "$chart_yaml")"
+[[ "$chart_version" == "$version" ]] ||
+ die "chart body reports version $chart_version but $version was requested. The published artifact is mislabelled; stop and investigate."
+[[ "$chart_app_version" == "$version" ]] ||
+ die "chart body reports appVersion $chart_app_version but $version was requested"
+log "chart body reports version $chart_version, appVersion $chart_app_version"
+
+step "Repackaging the pack chart archive"
+
+# helm package is used for its .helmignore handling, then the archive is
+# normalised. helm stamps the gzip header and file mtimes, so two runs on the
+# same version produce different bytes, which would show up as a spurious diff
+# every time the pipeline re-runs. Sorting entries and zeroing mtime, owner and
+# the gzip name field makes the archive a pure function of the chart contents.
+helm package "$pack_dir/charts/coder" --destination "$work" >/dev/null ||
+ die "helm package failed"
+
+staged="$work/normalise"
+mkdir -p "$staged"
+tar -xzf "$work/coder-$version.tgz" -C "$staged"
+
+chart_archive="charts/coder-$version.tgz"
+tar --sort=name \
+ --owner=0 --group=0 --numeric-owner \
+ --mtime='@0' \
+ --format=gnu \
+ -C "$staged" -cf - coder |
+ gzip -9 -n >"$pack_dir/$chart_archive"
+
+[[ -f "$pack_dir/$chart_archive" ]] ||
+ die "did not produce $chart_archive"
+
+# Guards against the exact defect shipped last time: an archive whose filename
+# advertises one version while its contents are another.
+repacked_version="$(tar -xzOf "$pack_dir/$chart_archive" coder/Chart.yaml | yq -r '.version')"
+[[ "$repacked_version" == "$version" ]] ||
+ die "repackaged archive reports version $repacked_version, expected $version"
+
+# The normalised archive must still be a chart Helm can read.
+helm show chart "$pack_dir/$chart_archive" >/dev/null ||
+ die "helm cannot read the normalised archive at $chart_archive"
+log "$chart_archive contains chart version $repacked_version, sha256 $(sha256sum "$pack_dir/$chart_archive" | cut -d' ' -f1)"
+
+step "Writing pack.json"
+
+# Derived from the previous pack.json so that annotations, layer, addonType and
+# cloudTypes carry forward untouched. Only the version and the chart path move.
+jq --arg v "$version" --arg c "$chart_archive" \
+ '.version = $v | .charts = [$c]' \
+ "$prev_pack/pack.json" >"$pack_dir/pack.json"
+
+got_name="$(jq -r '.name' "$pack_dir/pack.json")"
+got_display="$(jq -r '.displayName' "$pack_dir/pack.json")"
+[[ "$got_name" == "$PACK_NAME" ]] ||
+ die "pack.json name is '$got_name', expected '$PACK_NAME'. Changing it forks a new pack in Palette."
+[[ "$got_display" == "$PACK_DISPLAY_NAME" ]] ||
+ die "pack.json displayName is '$got_display', expected '$PACK_DISPLAY_NAME'. Changing it forks a new pack in Palette."
+log "identity preserved: name=$got_name displayName=$got_display"
+
+step "Generating values.yaml"
+
+# The pack values file is the chart's own values.yaml re-rooted under
+# charts., with a Palette metadata header on top.
+#
+# The nesting level matters and is the thing that was wrong before. The chart's
+# root keys are coder, provisionerDaemon and extraTemplates, so the correct
+# pack path is charts.coder.coder.*. Indenting the whole upstream file by one
+# level under "charts:\n coder:" produces that shape by construction, and it
+# stays correct as upstream adds or removes keys.
+#
+# pack.content.images drives airgap image mirroring and Spectro's security
+# scanning, and pack-central's CI resolves every entry with crane.
+{
+ printf 'pack:\n'
+ printf ' content:\n'
+ printf ' images:\n'
+ printf ' - image: %s:v%s\n' "$IMAGE_REPO" "$version"
+ printf ' charts:\n'
+ printf ' - repo: helm.coder.com/v2\n'
+ printf ' name: coder\n'
+ printf ' version: %s\n' "$version"
+ printf ' namespace: "coder"\n'
+ printf 'charts:\n'
+ printf ' coder:\n'
+ # Indent non-blank lines only. Padding blank lines would introduce trailing
+ # whitespace, which pack-central's values linter flags.
+ sed -e 's/^\(.\)/ \1/' "$pack_dir/charts/coder/values.yaml"
+} >"$pack_dir/values.yaml"
+
+if grep -qP '\t' "$pack_dir/values.yaml"; then
+ die "generated values.yaml contains tab characters"
+fi
+if grep -qE ' +$' "$pack_dir/values.yaml"; then
+ die "generated values.yaml contains trailing whitespace"
+fi
+
+pack_roots="$(yq -r '.charts.coder | keys | join(",")' "$pack_dir/values.yaml")"
+chart_roots="$(yq -r 'keys | join(",")' "$pack_dir/charts/coder/values.yaml")"
+[[ "$pack_roots" == "$chart_roots" ]] ||
+ die "charts.coder keys ($pack_roots) do not match the chart's root keys ($chart_roots)"
+log "charts.coder mirrors the chart root keys: $pack_roots"
+
+step "Adding logo.png and README.md"
+
+cp "$prev_pack/logo.png" "$pack_dir/logo.png"
+log "logo.png copied unchanged from ${prev_pack#"$pack_central"/}"
+
+readme_template="$SCRIPT_DIR/pack-README.md"
+[[ -f "$readme_template" ]] || die "README template not found at $readme_template"
+sed -e "s/__VERSION__/$version/g" "$readme_template" >"$pack_dir/README.md"
+if grep -q '__VERSION__' "$pack_dir/README.md"; then
+ die "README still contains an unsubstituted placeholder"
+fi
+
+# The README parameter table is hand-maintained, so it can silently start
+# lying when upstream renames or drops a value. Assert that every chart path it
+# advertises still exists.
+python3 - "$pack_dir/README.md" "$pack_dir/charts/coder/values.yaml" <<'PY' || die "README references chart values that no longer exist"
+import re
+import sys
+
+import yaml
+
+readme, chart_values = sys.argv[1], sys.argv[2]
+values = yaml.safe_load(open(chart_values))
+
+# First column of any markdown table row whose cell is a single code span.
+paths = []
+for line in open(readme):
+ m = re.match(r"^\|\s*`([A-Za-z][\w.]*)`\s*\|", line)
+ if m:
+ paths.append(m.group(1))
+
+missing = []
+for path in paths:
+ node = values
+ for part in path.split("."):
+ if isinstance(node, dict) and part in node:
+ node = node[part]
+ else:
+ missing.append(path)
+ break
+
+for path in missing:
+ print(f"ERROR: README documents '{path}', which is not a key in the chart values")
+
+print(f" checked {len(paths)} documented parameters, {len(missing)} missing")
+sys.exit(1 if missing else 0)
+PY
+
+step "Validating"
+
+# 1. The structural subset check that pack-central runs in CI. This is the gate
+# the current in-tree Coder pack fails with 27 errors.
+checker="$pack_central/validator/check-values-structure.py"
+[[ -f "$checker" ]] || die "validator not found at $checker"
+python3 "$checker" \
+ "$pack_dir/values.yaml" \
+ "$pack_dir/charts/coder/values.yaml" \
+ coder ||
+ die "pack values.yaml is not a structural subset of the chart values"
+log "structural subset check passed"
+
+# 2. Prove overrides actually reach Helm. Rendering with only the pack's own
+# chart subtree must produce the expected image, and flipping a value in
+# that subtree must change the render. A pack that ignores its own values
+# renders identically either way, which is how the previous version was
+# broken without anyone noticing.
+yq -r '.charts.coder' "$pack_dir/values.yaml" >"$work/render-values.yaml"
+rendered="$(helm template coder "$pack_dir/charts/coder" -f "$work/render-values.yaml" |
+ grep -oE "$IMAGE_REPO:v[0-9]+\.[0-9]+\.[0-9]+" | sort -u)"
+[[ "$rendered" == "$IMAGE_REPO:v$version" ]] ||
+ die "render produced image '$rendered', expected '$IMAGE_REPO:v$version'"
+log "render produces $rendered"
+
+yq '.coder.image.repo = "example.invalid/override-probe"' "$work/render-values.yaml" \
+ >"$work/probe-values.yaml"
+if ! helm template coder "$pack_dir/charts/coder" -f "$work/probe-values.yaml" |
+ grep -q 'example.invalid/override-probe'; then
+ die "override probe did not reach the rendered manifests. charts.coder is nested at the wrong depth and every user value would be silently ignored."
+fi
+log "override probe reached the rendered manifests"
+
+# 3. Every declared image must be resolvable, which is what pack-central's
+# validate_content does.
+while read -r img; do
+ [[ -n "$img" ]] || continue
+ crane manifest "$img" >/dev/null 2>&1 || die "cannot resolve image $img"
+ log "resolved $img"
+done < <(yq -r '.pack.content.images[].image' "$pack_dir/values.yaml")
+
+# 4. Required files, per validate_logo, validate_readme and validate_charts_exist.
+for required in pack.json values.yaml README.md logo.png "$chart_archive"; do
+ [[ -f "$pack_dir/$required" ]] || die "missing required file: $required"
+done
+jq empty "$pack_dir/pack.json" || die "pack.json is not valid JSON"
+log "required files present"
+
+step "Writing pack-summary.md"
+
+summary="$outdir/pack-summary.md"
+cat >"$summary" <&2
+printf 'Summary written to %s\n\n' "$summary" >&2
diff --git a/scripts/spectro/pack-README.md b/scripts/spectro/pack-README.md
new file mode 100644
index 0000000..b0619f0
--- /dev/null
+++ b/scripts/spectro/pack-README.md
@@ -0,0 +1,148 @@
+# Coder
+
+Coder provides self-hosted cloud development environments that run on your own public or private cloud infrastructure. Workspaces are defined with Terraform, connected through a secure high-speed tunnel, and automatically shut down when idle to save on costs. Developers connect to their workspaces with the tools they already use, including VS Code Remote, JetBrains Gateway, SSH, and web IDEs such as JupyterLab, code-server, and RStudio, while source code and credentials stay inside your infrastructure.
+
+## Prerequisites
+
+Review the Coder [Kubernetes installation guide](https://coder.com/docs/install/kubernetes) for the authoritative list of requirements. At a minimum, you need the following.
+
+- A Kubernetes cluster running version **1.19** or later. This is the `kubeVersion` constraint declared by the chart.
+- An external PostgreSQL database that is reachable from the cluster. The chart does not ship a database. You must supply the connection string through the `CODER_PG_CONNECTION_URL` environment variable, ideally sourced from a Kubernetes secret.
+- A resolvable access URL for the Coder deployment, set through the `CODER_ACCESS_URL` environment variable, and either an Ingress controller or a LoadBalancer service so that users and workspace agents can reach that URL.
+- A namespace to deploy into. The pack defaults to the `coder` namespace.
+- Cluster permissions to create the service account, Role, and RoleBinding that allow Coder to manage workspace pods and persistent volume claims.
+
+## Parameters
+
+The table below lists the most commonly used parameters. Paths are expressed relative to the Coder chart. In the pack's `values.yaml` these keys live under `charts.coder.`, so the chart parameter `coder.env` is set as `charts.coder.coder.env`.
+
+| **Parameter** | **Description** | **Type** | **Default Value** | **Required** |
+|---|---|---|---|---|
+| `coder.env` | Environment variables passed to `coder server`. Use this to set `CODER_ACCESS_URL` and `CODER_PG_CONNECTION_URL`. Each entry supports `value` or `valueFrom`. | List | `[]` | Yes |
+| `coder.envFrom` | ConfigMaps or secrets to import wholesale as environment variables. If you set `CODER_ACCESS_URL` here, you must also set `coder.envUseClusterAccessURL` to `false`. | List | `[]` | No |
+| `coder.image.repo` | The repository of the Coder image. | String | `ghcr.io/coder/coder` | No |
+| `coder.image.tag` | The image tag. When empty, the chart `appVersion` is used, which is `__VERSION__` for this pack version. | String | `""` | No |
+| `coder.image.pullPolicy` | The image pull policy for the Coder container. | String | `IfNotPresent` | No |
+| `coder.replicaCount` | The number of deployment replicas. Only increase this when High Availability is licensed and enabled. | Int | `1` | No |
+| `coder.resources` | CPU and memory requests and limits for the Coder container. Unset by default, so no requests or limits are applied. | Object | empty | No |
+| `coder.service.type` | The type of Service to create for Coder. | String | `LoadBalancer` | No |
+| `coder.service.loadBalancerIP` | A static IP address for the LoadBalancer. When empty, a new address is allocated each time the load balancer is recreated. | String | `""` | No |
+| `coder.ingress.enable` | Whether to create an Ingress object for Coder. | Bool | `false` | No |
+| `coder.ingress.className` | The name of the Ingress class to use. | String | `""` | No |
+| `coder.ingress.host` | The hostname the Ingress matches on. Set `CODER_ACCESS_URL` in `coder.env` to the same host. | String | `""` | No |
+| `coder.ingress.tls.enable` | Whether to enable TLS on the Ingress. | Bool | `false` | No |
+| `coder.ingress.tls.secretName` | The name of the TLS secret used by the Ingress host. A separate `coder.ingress.tls.wildcardSecretName` covers the wildcard host. | String | `""` | No |
+| `coder.tls.secretNames` | TLS server certificate secrets of type `kubernetes.io/tls` mounted into the Coder pod for TLS termination by Coder itself. Leave empty when an Ingress terminates TLS. | List | `[]` | No |
+| `coder.serviceAccount.workspacePerms` | Whether to grant the Coder service account permission to manage workspace pods and persistent volume claims in the deployment namespace. Keep this enabled when using Kubernetes workspace templates. | Bool | `true` | No |
+| `coder.serviceAccount.workspaceNamespaces` | Additional namespaces where Roles and RoleBindings are created so Coder can manage workspaces there without cluster-wide permissions. | List | `[]` | No |
+| `coder.readinessProbe.enabled` | Whether to enable the readiness probe on the Coder container. | Bool | `true` | No |
+| `coder.livenessProbe.enabled` | Whether to enable the liveness probe on the Coder container. | Bool | `false` | No |
+| `coder.podSecurityContext` | Pod-level security context, commonly used to set `fsGroup` so mounted certificate secrets are readable by the Coder user. | Object | `{}` | No |
+| `coder.priorityClassName` | The PriorityClass assigned to the Coder pod. The PriorityClass must already exist in the cluster. | String | `""` | No |
+
+For the complete list of supported parameters, review the chart [values.yaml](https://github.com/coder/coder/blob/main/helm/coder/values.yaml).
+
+## Upgrade
+
+> [!CAUTION]
+> Overrides now take effect. In pack version 2.23.3 and earlier, the chart values in the pack's `values.yaml` were nested one level too shallow, so Palette overrides were silently ignored and the deployment always ran with chart defaults. This pack version corrects the nesting to `charts.coder.coder.*`. If you previously set values in Palette, review every one of them before upgrading, because they will now be applied to the deployment for the first time.
+
+> [!IMPORTANT]
+> `coder.livenessProbe.enabled` now defaults to `false`. In the chart shipped by earlier pack versions the liveness probe was applied unconditionally. If you rely on the liveness probe, set `coder.livenessProbe.enabled` to `true` explicitly.
+
+Additional upgrade notes:
+
+- If you are upgrading from pack version 2.23.3, note that it shipped upstream chart content from version 2.21.3, so the upgrade spans upstream releases 2.21.3 to __VERSION__. Read the Coder [releases and support policy](https://coder.com/docs/install/releases) before planning the change.
+- Follow the Coder [Helm upgrade steps](https://coder.com/docs/install/kubernetes#upgrading-coder-via-helm) and back up the PostgreSQL database first. The database holds all deployment state, so a verified backup is the rollback path if you need to return to the previous version.
+- New opt-in Gateway API templates are available through `coder.httproute` and `coder.listenerset`. Both default to disabled. Enabling them requires Gateway API v1.5.0 or later CRDs, a Gateway controller that supports ListenerSet, and a parent Gateway with `allowedListeners` configured.
+- After the upgrade, confirm the rollout completes and the deployment is healthy:
+
+```bash
+kubectl rollout status deployment/coder -n coder
+kubectl get pods -n coder
+```
+
+## Usage
+
+Add the Coder pack to an add-on cluster profile, then override the default pack configuration. All chart values sit under `charts.coder.coder` in the pack's `values.yaml`.
+
+### 1. Create the PostgreSQL secret
+
+Coder requires an external PostgreSQL database. Create the secret in the same namespace as the deployment before adding the pack, or add it as a manifest layer in the cluster profile. Never inline the database password in the pack values.
+
+```bash
+kubectl create namespace coder
+kubectl create secret generic coder-db-url \
+ --namespace coder \
+ --from-literal=url="postgres://coder:PASSWORD@postgres.example.com:5432/coder?sslmode=require"
+```
+
+### 2. Set the access URL and database connection
+
+Set `CODER_ACCESS_URL` to the URL users will open in their browser, and read the database DSN from the secret with `valueFrom.secretKeyRef`.
+
+```yaml
+charts:
+ coder:
+ coder:
+ env:
+ - name: CODER_ACCESS_URL
+ value: "https://coder.example.com"
+ - name: CODER_PG_CONNECTION_URL
+ valueFrom:
+ secretKeyRef:
+ name: coder-db-url
+ key: url
+```
+
+### 3. Expose Coder
+
+Use an Ingress when the cluster already has an Ingress controller and you want the controller to terminate TLS. Leave `coder.tls.secretNames` empty in this case.
+
+```yaml
+charts:
+ coder:
+ coder:
+ service:
+ type: ClusterIP
+ ingress:
+ enable: true
+ className: nginx
+ host: coder.example.com
+ tls:
+ enable: true
+ secretName: coder-tls
+```
+
+Use the default LoadBalancer service when no Ingress controller is available. Set a static address with `coder.service.loadBalancerIP` in production so the access URL does not change when the load balancer is recreated.
+
+```yaml
+charts:
+ coder:
+ coder:
+ service:
+ type: LoadBalancer
+ loadBalancerIP: "203.0.113.10"
+ ingress:
+ enable: false
+```
+
+If you serve workspace applications over subdomains, also set `coder.ingress.wildcardHost` and the `CODER_WILDCARD_ACCESS_URL` environment variable.
+
+### 4. Create the first administrator
+
+After the deployment becomes ready, open the access URL in a browser and create the first administrator account. Follow the Coder [first login steps](https://coder.com/docs/install/kubernetes#5-log-in-to-coder-) to complete setup, then create a workspace template to let developers provision workspaces.
+
+```bash
+kubectl get pods -n coder
+kubectl get svc coder -n coder
+```
+
+## References
+
+- [Coder documentation](https://coder.com/docs)
+- [Install Coder on Kubernetes](https://coder.com/docs/install/kubernetes)
+- [Coder Helm chart values.yaml](https://github.com/coder/coder/blob/main/helm/coder/values.yaml)
+- [Coder releases and support policy](https://coder.com/docs/install/releases)
+- [Coder quickstart](https://coder.com/docs/tutorials/quickstart)
+- [Coder Discord community](https://discord.gg/coder)