Compare commits
154
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddd3df4675 | ||
|
|
b047a7a569 | ||
|
|
8a415e9745 | ||
|
|
a830325d31 | ||
|
|
d1e6edf6d1 | ||
|
|
2c036d3a61 | ||
|
|
11fcb7b145 | ||
|
|
f7c2309483 | ||
|
|
33ceb2c495 | ||
|
|
9a6647cd30 | ||
|
|
0539ecf756 | ||
|
|
bd06e29b29 | ||
|
|
5e0e4f906c | ||
|
|
f098b39ecd | ||
|
|
674fabdcba | ||
|
|
3f65c9e24f | ||
|
|
8e43277aab | ||
|
|
d3823df85a | ||
|
|
b6b52b4a81 | ||
|
|
331e05625f | ||
|
|
44186bea06 | ||
|
|
0e1a56bd3c | ||
|
|
23862e5c44 | ||
|
|
74c176639b | ||
|
|
b323a1189d | ||
|
|
5750426c73 | ||
|
|
093b912e51 | ||
|
|
8edb99bb19 | ||
|
|
9f6bfb8cff | ||
|
|
a2d50bce29 | ||
|
|
d31d20f679 | ||
|
|
f01beb8f6f | ||
|
|
07a8c94b14 | ||
|
|
c1bd70ca94 | ||
|
|
c9b7153152 | ||
|
|
7cb8c69316 | ||
|
|
81eee4e18d | ||
|
|
d9c8917e18 | ||
|
|
31817412ec | ||
|
|
97d22d6a43 | ||
|
|
14314da268 | ||
|
|
bd016f7a80 | ||
|
|
41947eca48 | ||
|
|
ec658b8dea | ||
|
|
53d4a378d7 | ||
|
|
a43f5a33fe | ||
|
|
108e4f4987 | ||
|
|
830171c4c7 | ||
|
|
8cf87a4082 | ||
|
|
eb9e7cbb96 | ||
|
|
5f66b28973 | ||
|
|
7e1de4c35f | ||
|
|
a3f14fbf4c | ||
|
|
f2066f1333 | ||
|
|
7243683ee8 | ||
|
|
ec73a49f84 | ||
|
|
2c918f5368 | ||
|
|
66d4f21ba8 | ||
|
|
19cc5d61c9 | ||
|
|
df6c025df0 | ||
|
|
7ad9222282 | ||
|
|
fffae20a9e | ||
|
|
f8ab325ace | ||
|
|
31ea5caf86 | ||
|
|
8c54cfea8e | ||
|
|
aeb6adf4d5 | ||
|
|
92143e5e8b | ||
|
|
05b75b2fd2 | ||
|
|
3445d7acfe | ||
|
|
844e746c32 | ||
|
|
c4e3445839 | ||
|
|
58ed7ea7b4 | ||
|
|
0b976af3e1 | ||
|
|
0b7a45a7d9 | ||
|
|
ddf123485a | ||
|
|
522aa9e17b | ||
|
|
6a755f27b5 | ||
|
|
a66bd46cb2 | ||
|
|
f19e157205 | ||
|
|
fe00b22aaf | ||
|
|
9a5ba3d5c7 | ||
|
|
123a72df80 | ||
|
|
ceb720a5b5 | ||
|
|
eb8eac8c54 | ||
|
|
15e7f01e01 | ||
|
|
7f02e0645b | ||
|
|
4c55afe813 | ||
|
|
51b2286b79 | ||
|
|
2127fdf7dc | ||
|
|
7077cec764 | ||
|
|
25548f34a0 | ||
|
|
1aae2b65a3 | ||
|
|
fcdb02af5f | ||
|
|
93eafe4f55 | ||
|
|
bd4d78c3a2 | ||
|
|
4fcc2e02ce | ||
|
|
d9d6a0f265 | ||
|
|
2866795e2d | ||
|
|
e4509f4a8b | ||
|
|
3083524803 | ||
|
|
c5df463e34 | ||
|
|
313180e201 | ||
|
|
ca618492db | ||
|
|
76cf840edb | ||
|
|
fb7f460370 | ||
|
|
28f1664092 | ||
|
|
120983607c | ||
|
|
f3acdc8cf7 | ||
|
|
76324c93b6 | ||
|
|
02370735a0 | ||
|
|
cb113a3cee | ||
|
|
39553ee364 | ||
|
|
0737508323 | ||
|
|
5ee1b857de | ||
|
|
4900364b02 | ||
|
|
e628b788c4 | ||
|
|
1edfdfbb1c | ||
|
|
56e83218ca | ||
|
|
3e04067597 | ||
|
|
b5df8baca7 | ||
|
|
ece186fab6 | ||
|
|
3668bb9ebd | ||
|
|
e462b8bf81 | ||
|
|
86428e2f8a | ||
|
|
887fa3962e | ||
|
|
9f4fd9a959 | ||
|
|
b67fa75bbf | ||
|
|
f7426add1d | ||
|
|
109c92392b | ||
|
|
0770a28962 | ||
|
|
5e19ed47ef | ||
|
|
5106bdeada | ||
|
|
a9113929f9 | ||
|
|
0339fc7dee | ||
|
|
787e41007d | ||
|
|
0542e97a06 | ||
|
|
c2f31a8ab7 | ||
|
|
265f807635 | ||
|
|
f79a400640 | ||
|
|
c06948b467 | ||
|
|
192a9a2f7e | ||
|
|
2ef5d47f94 | ||
|
|
3aa73cb604 | ||
|
|
3a9e055c45 | ||
|
|
e262ea40f9 | ||
|
|
8ae1c46625 | ||
|
|
9d18d4a6ba | ||
|
|
ecc3da75f0 | ||
|
|
d425a88e9a | ||
|
|
fb096091b3 | ||
|
|
aadb7a04c0 | ||
|
|
599667e100 | ||
|
|
2bc3b2e1a9 | ||
|
|
572c712140 |
@@ -0,0 +1,59 @@
|
||||
name: imgver
|
||||
description: The semver an image build publishes. We don't ship shas.
|
||||
# For the repos that build images from a hand-rolled .hanzo/workflows/deploy.yml
|
||||
# instead of importing hanzoai/ci's build.yml. Those workflows each carry their
|
||||
# own `tag=sha-$(echo $GITHUB_SHA | cut -c1-7)` line, which is why 14 of the
|
||||
# fleet's 117 pins named a commit rather than a release. Replace that line with:
|
||||
#
|
||||
# - id: ver
|
||||
# uses: hanzoai/ci/.github/actions/imgver@v1
|
||||
# with: { repo: ghcr.io/hanzoai/<name> }
|
||||
# env: { GH_PAT: '${{ secrets.GH_PAT }}' }
|
||||
# ...
|
||||
# tags: ghcr.io/hanzoai/<name>:${{ steps.ver.outputs.version }}
|
||||
#
|
||||
# and keep the sha tag alongside if you want the forensics. The version is the
|
||||
# one universe PINS. Same bin/imgver build.yml runs — one implementation.
|
||||
inputs:
|
||||
repo:
|
||||
description: Image repository, e.g. ghcr.io/hanzoai/iam
|
||||
required: true
|
||||
context:
|
||||
description: Build context, where the version manifest is looked for first
|
||||
required: false
|
||||
default: .
|
||||
version:
|
||||
description: >-
|
||||
Override the declared version: a literal x.y.z, or "<file>:<command
|
||||
printing it>". Defaults to the repo's package.json / Cargo.toml / VERSION
|
||||
/ pyproject.toml.
|
||||
required: false
|
||||
default: ''
|
||||
outputs:
|
||||
version:
|
||||
description: The semver to publish and to pin
|
||||
value: ${{ steps.run.outputs.version }}
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Fetch imgver
|
||||
shell: bash
|
||||
# The action ref is the script ref: an action pinned to @v1 runs v1's
|
||||
# imgver. Both forges, because this repo is served from each.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ref="${GITHUB_ACTION_REF:-v1}"
|
||||
for url in https://github.com/hanzoai/ci https://git.hanzo.ai/hanzo/ci; do
|
||||
git clone -q --depth 1 --branch "$ref" "$url" "$RUNNER_TEMP/imgver-ci" 2>/dev/null && break
|
||||
done
|
||||
[ -x "$RUNNER_TEMP/imgver-ci/bin/imgver" ] \
|
||||
|| { echo "::error::could not fetch hanzoai/ci@$ref (bin/imgver)"; exit 1; }
|
||||
- id: run
|
||||
shell: bash
|
||||
env:
|
||||
IMGVER_VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
v=$("$RUNNER_TEMP/imgver-ci/bin/imgver" '${{ inputs.repo }}' '${{ inputs.context }}')
|
||||
echo "version=$v" >> "$GITHUB_OUTPUT"
|
||||
echo "$v"
|
||||
@@ -0,0 +1,60 @@
|
||||
name: sitedeploy
|
||||
description: Publish a built static export to the Hanzo PaaS Sites plane.
|
||||
# For every repo whose deploy is "a static export goes live" — hanzo.ai, hanzo.app,
|
||||
# hips, computer and the ones after them. Those were each about to carry their own
|
||||
# copy of enqueue → upload → complete, which is how one contract becomes N
|
||||
# transcriptions that drift (see the imgver action next door for the same story
|
||||
# told about image tags).
|
||||
#
|
||||
# - uses: hanzoai/ci/.github/actions/sitedeploy@v1
|
||||
# with: { slug: hanzo-ai, dir: out }
|
||||
# env: { HANZO_DEPLOY_TOKEN: '${{ secrets.HANZO_DEPLOY_TOKEN }}' }
|
||||
#
|
||||
# A SITE IS NOT AN APP. This publishes files and stops: no image, no CR, no
|
||||
# replicas, no registry. Building a container so a Go binary can serve /public is
|
||||
# the shape the Sites plane exists to retire.
|
||||
#
|
||||
# ONE credential. The 202 hands back a prefix-scoped, 30-minute presigned POST
|
||||
# grant, so CI never holds a bucket key — do NOT add SITES_S3_* here; that is the
|
||||
# standing shared-bucket credential the grant replaced.
|
||||
inputs:
|
||||
slug:
|
||||
description: The project slug on the Sites plane (POST /v1/projects/<slug>/deploy)
|
||||
required: true
|
||||
dir:
|
||||
description: The built export directory
|
||||
required: true
|
||||
api:
|
||||
description: Cloud API base
|
||||
required: false
|
||||
default: https://api.hanzo.ai
|
||||
jobs:
|
||||
description: Parallel uploads
|
||||
required: false
|
||||
default: '24'
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Fetch sitedeploy
|
||||
shell: bash
|
||||
# The action ref is the script ref: an action pinned to @v1 runs v1's
|
||||
# sitedeploy. Both forges, because this repo is served from each.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ref="${GITHUB_ACTION_REF:-v1}"
|
||||
for url in https://git.hanzo.ai/hanzoai/ci https://github.com/hanzoai/ci; do
|
||||
git clone -q --depth 1 --branch "$ref" "$url" "$RUNNER_TEMP/sitedeploy-ci" 2>/dev/null && break
|
||||
done
|
||||
[ -x "$RUNNER_TEMP/sitedeploy-ci/bin/sitedeploy" ] \
|
||||
|| { echo "::error::could not fetch hanzoai/ci@$ref (bin/sitedeploy)"; exit 1; }
|
||||
|
||||
- name: Deploy
|
||||
shell: bash
|
||||
env:
|
||||
HANZO_API: ${{ inputs.api }}
|
||||
SITEDEPLOY_JOBS: ${{ inputs.jobs }}
|
||||
SITEDEPLOY_COMMIT: ${{ github.sha }}
|
||||
SITEDEPLOY_BRANCH: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
bash "$RUNNER_TEMP/sitedeploy-ci/bin/sitedeploy" '${{ inputs.slug }}' '${{ inputs.dir }}'
|
||||
+1618
-46
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
# `go build ./...` writes the binary into the package directory, named after the
|
||||
# package — which is how a 12MB darwin/arm64 `ci` came to be committed here, in
|
||||
# a repo whose image is built linux/amd64 from source by the Dockerfile. The
|
||||
# artifact is never an input to anything; only the source is.
|
||||
/ci
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
name: CI/CD
|
||||
# The gate for hanzoai/ci itself, on our own runners against git.hanzo.ai.
|
||||
#
|
||||
# THE LAW: `.github/workflows` runs zero CI; everything that gates or builds
|
||||
# lives here. All real config is the repo-root hanzo.yml — this file is the
|
||||
# ~7-line caller, exactly like cloud/commerce/console/git.
|
||||
#
|
||||
# Self-referential on purpose: this repo's dashboard image is built by this
|
||||
# repo's own reusable pipeline, pinned at the @v2 TAG rather than at the working
|
||||
# tree. That pin is what keeps a broken edit to build.yml from also breaking the
|
||||
# build that would have caught it — the tag moves only when a release is cut.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# A v* tag is what produces a published, immutable image tag (branch pushes
|
||||
# only ever yield sha-<sha7>). Without this trigger a release tag builds
|
||||
# nothing at all and the CR has no version to pin.
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
concurrency:
|
||||
group: cicd-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
gate:
|
||||
uses: hanzoai/ci/.hanzo/workflows/build.yml@v1
|
||||
secrets: inherit
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
#
|
||||
# ci — the ci.hanzo.ai dashboard. Pure-Go, no cgo, no node: the page is
|
||||
# server-rendered from a template compiled into the binary, and the design
|
||||
# tokens it spends are @hanzo/brand's published stylesheet, go:embed-ed beside
|
||||
# it. So the image is still the binary and a CA bundle — nothing served from
|
||||
# disk, nothing to go stale against the code, and no JS toolchain on the path
|
||||
# that ships the board you read when the builds are broken.
|
||||
FROM golang:1.26.5-alpine AS builder
|
||||
WORKDIR /build
|
||||
# Resolve through the module proxy: proxy.golang.org and sum.golang.org agree
|
||||
# and neither can change under us, which a direct fetch against a moved tag
|
||||
# cannot promise.
|
||||
ENV GOPROXY=https://proxy.golang.org,direct
|
||||
# The base image above is pinned to exactly the Go go.mod asks for, so nothing
|
||||
# is downloaded here — the pin is what makes this build hermetic. GOTOOLCHAIN
|
||||
# is set to auto anyway, because the golang images default it to `local` and
|
||||
# that turns the NEXT go.mod bump from "fetches the toolchain it needs" into
|
||||
# "dies mid-build with go.mod requires go >= X". The pin is the fast path; this
|
||||
# is the one that keeps a version bump from being a build break. bin/gover
|
||||
# gates the same rule for every repo this pipeline builds.
|
||||
ENV GOTOOLCHAIN=auto
|
||||
COPY go.mod ./
|
||||
RUN --mount=type=cache,id=ci-gomod,target=/go/pkg/mod go mod download
|
||||
COPY . .
|
||||
RUN --mount=type=cache,id=ci-gomod,target=/go/pkg/mod \
|
||||
--mount=type=cache,target=/root/.cache/go-build \
|
||||
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /build/ci .
|
||||
|
||||
FROM alpine:3.21
|
||||
RUN apk add --no-cache ca-certificates tzdata \
|
||||
&& addgroup -S hanzo && adduser -S hanzo -G hanzo
|
||||
COPY --from=builder /build/ci /app/ci
|
||||
USER hanzo
|
||||
EXPOSE 8080
|
||||
# Liveness only. Readiness deliberately does not gate on having a snapshot — see
|
||||
# the /healthz comment in main.go: a Hanzo Git outage must render as a dashboard
|
||||
# saying so, not as this pod leaving the load balancer as well.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||
CMD wget -qO- http://127.0.0.1:8080/healthz || exit 1
|
||||
ENTRYPOINT ["/app/ci"]
|
||||
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2026 Hanzo AI, Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -40,10 +40,181 @@ jobs:
|
||||
|
||||
That's it. The build/test/deploy logic lives here, once.
|
||||
|
||||
## Runners — our cloud or your own
|
||||
## `client:` — one document, eight generated clients
|
||||
|
||||
By default the build runs on the **Hanzo cloud** arc pool (we run it; metered as
|
||||
build minutes). To run on **your own** self-hosted arc runners, pass their labels:
|
||||
A generated SDK is a **projection** of one API document at one version. This lane
|
||||
is the only place in the fleet that says how a projection is made, so the eight
|
||||
client repos (`python-sdk`, `js-sdk`, `go-sdk`, `rust-sdk`, `java-sdk`,
|
||||
`kotlin-sdk`, `cpp-sdk`, `cli`) stop carrying eight copies of the same eight
|
||||
lines.
|
||||
|
||||
```yaml
|
||||
client:
|
||||
spec: { repo: hanzoai/cloud, path: openapi.yaml } # these are the defaults
|
||||
generate: ./scripts/generate.sh # $SPEC is the fetched document
|
||||
version: 'package.json:jq -r .version package.json' # optional; see below
|
||||
```
|
||||
|
||||
There is deliberately **no `build:`**. The repo already declared how it proves
|
||||
itself, in `test:`, and that block runs over the regenerated tree — which is
|
||||
exactly the gate. A second declaration would be one assertion written twice.
|
||||
|
||||
It fires on `repository_dispatch: spec-update`, which **hanzoai/cloud sends once
|
||||
per release**:
|
||||
|
||||
```yaml
|
||||
on:
|
||||
repository_dispatch: { types: [spec-update] }
|
||||
workflow_dispatch:
|
||||
```
|
||||
|
||||
The coupler is the document, **passed by value at a pinned ref**. The payload
|
||||
carries `(version, sha, spec_sha256)`; the lane fetches `openapi.yaml` at that
|
||||
sha and refuses if the bytes hash to anything else — every projection of one
|
||||
release is generated from one digest. Reading a live host instead would be a lie
|
||||
about which deploy the client describes.
|
||||
|
||||
Three gates, in order:
|
||||
|
||||
| gate | refuses |
|
||||
|---|---|
|
||||
| digest | a client generated from a different document than its siblings |
|
||||
| `test:` | a spec change that produces a client which does not compile — **including its examples** |
|
||||
| `.spec-lock` | is committed beside the code: `ref` + `sha256`, so anyone can ask a client repo *which document are you?* without running a generator |
|
||||
|
||||
On a delta — and only after `test:` has passed over exactly those bytes — the
|
||||
lane commits the projection, bumps the **patch** (derived, never typed: a
|
||||
projection never earns a minor or a major) and pushes the tag. The repo's own tag
|
||||
lane publishes it, so the registry credential stays where the publish is.
|
||||
|
||||
`version:` says **where this client's version lives**, because that answer is
|
||||
genuinely different per language:
|
||||
|
||||
| value | meaning |
|
||||
|---|---|
|
||||
| `"<file>:<command printing it>"` | it lives in a file — rewrite it, commit, tag |
|
||||
| `tag` | the tag **is** the version (a Go module has nothing to rewrite) |
|
||||
| absent | CI cannot derive one — the projection is committed and gated, nothing is cut |
|
||||
|
||||
The third state is not a gap to fill later. A repo whose version is not `x.y.z`
|
||||
(a `-alpha.N` gradle build) has no patch for this lane to derive, and guessing
|
||||
one would tag bytes under a number nobody chose.
|
||||
|
||||
Credential: **`SPEC_TOKEN`** — a fine-grained token with `contents:read` on the
|
||||
spec repo.
|
||||
|
||||
## `binaries:` — publish a plugin once, install it everywhere
|
||||
|
||||
`images:` ships an OCI image a **cluster** runs. `binaries:` ships an
|
||||
executable a **running host** installs: a [zip](https://github.com/zap-proto/zip)
|
||||
plugin, fetched at run time by URL and verified against its SHA-256 before it is
|
||||
ever made executable. Build it once per OS/arch here; every host picks up the
|
||||
same bits, and nobody rebuilds the world to ship a plugin.
|
||||
|
||||
```yaml
|
||||
binaries:
|
||||
- name: billing
|
||||
main: ./cmd/billing # the Go package; default "."
|
||||
platforms: [linux/amd64, linux/arm64] # default [linux/amd64]
|
||||
ldflags: "-s -w" # default
|
||||
```
|
||||
|
||||
`main:` is the zero-config **Go** lane. Every other toolchain uses the same block
|
||||
with `run:` (the command that builds) and `out:` (the glob of what it produced) —
|
||||
which is how a repo with no Dockerfile and no Go still publishes an artifact:
|
||||
|
||||
```yaml
|
||||
binaries:
|
||||
- name: sdk
|
||||
run: npm install && npm run build && npm pack --pack-destination .
|
||||
out: "*.tgz"
|
||||
image: node:22-bookworm # the toolchain — see below
|
||||
```
|
||||
|
||||
`image:` names the container the **platform** lane runs `run:` in
|
||||
(`POST /v1/runner`, one initContainer per entry, in-cluster). Here the toolchain
|
||||
IS the runner, so this workflow reads past it. It is not a second recipe: both
|
||||
lanes read the same `binaries:` block out of the same `hanzo.yml` and publish the
|
||||
same `binaries.json` at the same URL.
|
||||
|
||||
Artifacts land under `<name>` in the index regardless of lane; a `run:` entry is
|
||||
`os: any, arch: any`, because an npm tarball or a wheel is not per-platform and
|
||||
an index entry that claimed one would be a lie a host acts on.
|
||||
|
||||
Built on every push (an arm64 cross-compile that breaks fails the PR that broke
|
||||
it) and **published on a tag**, after the `test:` gate — a host installs an
|
||||
artifact unattended, so the tests gate the bits. Each artifact lands on the
|
||||
GitHub Release for that tag:
|
||||
|
||||
```
|
||||
https://github.com/<owner>/<repo>/releases/download/<tag>/<name>-<os>-<arch>
|
||||
```
|
||||
|
||||
plus `binaries.json` beside them — `{name, os, arch, url, sha256}` for every
|
||||
artifact, so the bits and the digest that authorizes them ship as one release
|
||||
and a host reads both from one place. The job summary prints the
|
||||
`zip.Load(zip.Plugin{URL, Sum})` a host pastes.
|
||||
|
||||
Add a top-level `bucket:` and they publish to **hanzoai/s3** instead — same
|
||||
artifacts, same index, only the url changes:
|
||||
|
||||
```yaml
|
||||
bucket: plugins # → https://s3.hanzo.ai/plugins/<owner>/<repo>/<tag>/binaries.json
|
||||
```
|
||||
|
||||
Credentials are the `S3_ADMIN_*` names the services already read, pulled from
|
||||
KMS at run time; a declared bucket with no credential fails the publish rather
|
||||
than shipping an index whose artifacts are missing. Use it for anything large or
|
||||
frequent — a GitHub release stores it on a quota we do not own.
|
||||
|
||||
Builds are `CGO_ENABLED=0 -trimpath`: the host that installs this runs it on
|
||||
whatever base image the host is, and the digest must be a function of the
|
||||
source, not of the checkout path.
|
||||
|
||||
## `site:` — a static export, promoted to an immutable release
|
||||
|
||||
`images:` ships an OCI image a **cluster** runs; `binaries:` ships an executable
|
||||
a **host** installs. `site:` ships a static export an **edge** serves — no image,
|
||||
no CR, no replicas. Building a container so a Go binary can serve `/public` is
|
||||
the shape this retires.
|
||||
|
||||
```yaml
|
||||
site:
|
||||
slug: hanzo-console # the project on the Sites plane
|
||||
dir: out # the built export; needs index.html at its root
|
||||
build: npm ci && npm run build # optional; run first
|
||||
on: [main] # same branch gate as deploy.on; tags always publish
|
||||
```
|
||||
|
||||
That is the whole configuration. **There is no credential to provision**: the
|
||||
bearer is the IAM JWT the workflow already mints from `KMS_CLIENT_ID` /
|
||||
`KMS_CLIENT_SECRET`, so a repo that can build can publish. CI names no bucket and
|
||||
no org — the org segment is prepended server-side from the validated principal,
|
||||
which is what makes the prefix unforgeable.
|
||||
|
||||
The export is zipped and posted to `/v1/projects/<slug>/deploy`, then that prefix
|
||||
is promoted by `/v1/sites/<slug>/publish` into an **immutable release** whose id
|
||||
digests its object manifest. The site's pointer is flipped to it, and the step
|
||||
then re-reads the release list and refuses unless the release it just published
|
||||
is the one that is live. Rollback is the same pointer aimed at an older release.
|
||||
|
||||
**One size boundary, and it is the server's.** cloud's public edge caps a request
|
||||
body at 16 MiB (`GATEWAY_BODY_LIMIT`) and refuses a larger POST before any
|
||||
handler runs — reporting only `Error when parsing request`, which names neither
|
||||
size nor cause. `bin/sitepublish` therefore measures the zip and refuses *with
|
||||
the number* first. Of the 24 built exports in the estate, 22 fit; the two that do
|
||||
not (`hanzo.ai` at 27.9 MiB zipped and 8,536 files, `trillerfest.com` at 76.7
|
||||
MiB) use [`bin/sitedeploy`](bin/sitedeploy), which streams per-file against a
|
||||
presigned grant and has no body limit. `hanzo.ai` is also past the server's own
|
||||
5,000-entry cap, so no transport makes that export a release.
|
||||
|
||||
## Runners — our fleet or your own
|
||||
|
||||
By default the build runs on the **Hanzo `git-runner` fleet** on git.hanzo.ai
|
||||
(we run it; metered as build minutes) — the only pool that serves the default
|
||||
`hanzo-build-linux-amd64` label. There is no arc pool: arc (arcd) was retired
|
||||
2026-08-01 and never served any label in this default. To run on **your own**
|
||||
self-hosted runners, pass their labels:
|
||||
|
||||
```yaml
|
||||
uses: hanzoai/ci/.github/workflows/build.yml@v1
|
||||
@@ -52,6 +223,33 @@ build minutes). To run on **your own** self-hosted arc runners, pass their label
|
||||
secrets: inherit
|
||||
```
|
||||
|
||||
## Delegate to platform (skip runner buildx)
|
||||
|
||||
By default the build runs buildx **on** the runner. To instead hand the build
|
||||
to **platform.hanzo.ai** — which builds in-cluster with BuildKit and rolls the
|
||||
service itself — pass `mode: delegate`:
|
||||
|
||||
```yaml
|
||||
uses: hanzoai/ci/.github/workflows/build.yml@v1
|
||||
with:
|
||||
mode: delegate
|
||||
secrets: inherit
|
||||
```
|
||||
|
||||
The GitHub job then just POSTs each image in `hanzo.yml` to platform's direct
|
||||
build webhook (`/v1/runner`) and exits in **seconds** — no runner buildx,
|
||||
no KMS, no runner-side deploy. Platform creates the build job, launches an
|
||||
in-cluster BuildKit Job on its own pool, pushes to the registry, and patches the
|
||||
operator `Service` CR to roll it. It's the same build path as the platform
|
||||
GitHub-App webhook — one build path, two front doors.
|
||||
|
||||
Requires one extra secret, `PLATFORM_BUILD_CALLBACK_TOKEN` (org- or repo-level,
|
||||
picked up via `secrets: inherit`). Override the endpoint with the
|
||||
`PLATFORM_ENQUEUE_URL` repo/org variable (default `https://platform.hanzo.ai/v1/runner`).
|
||||
|
||||
`mode: buildx` (the default) is unchanged — existing repos keep running buildx on
|
||||
the fleet runner, so delegation is strictly opt-in.
|
||||
|
||||
## Credentials
|
||||
|
||||
The only GitHub secrets a repo sets are `KMS_CLIENT_ID` / `KMS_CLIENT_SECRET`
|
||||
@@ -62,6 +260,6 @@ run time. No long-lived registry or cluster credentials live in GitHub.
|
||||
## Platform-native
|
||||
|
||||
`hanzo.yml` is also read by platform.hanzo.ai: a repo on the platform webhook
|
||||
needs **only** `hanzo.yml` — the platform builds it on arc and rolls it out, no
|
||||
needs **only** `hanzo.yml` — the platform builds it in-cluster and rolls it out, no
|
||||
workflow file at all. This reusable is the GitHub-Actions path for repos that
|
||||
trigger through GitHub instead of the platform.
|
||||
|
||||
Executable
+112
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env bash
|
||||
# conflictmarkers — refuse a repo that has committed an unresolved merge.
|
||||
# One implementation, every caller.
|
||||
#
|
||||
# conflictmarkers [dir]
|
||||
#
|
||||
# WHAT THIS CATCHES
|
||||
#
|
||||
# `git merge` writes its disagreement INTO the file and stops. Resolving means
|
||||
# editing those lines out; nothing forces you to. `git add` on a file that still
|
||||
# contains them is accepted without complaint, and from that moment the markers
|
||||
# are ordinary committed content — `git status` is clean, `git diff` is empty,
|
||||
# and the conflict is now a permanent feature of the branch.
|
||||
#
|
||||
# It survives because of WHERE it lands. The file is usually generated or
|
||||
# vendored — large, rarely opened, excluded from review by its own size. Nobody
|
||||
# reads line 23,807 of a .d.ts. hanzoai/base carried exactly this on main:
|
||||
#
|
||||
# plugins/jsvm/internal/types/generated/types.d.ts:23807 <<<<<<< HEAD
|
||||
# plugins/jsvm/internal/types/generated/types.d.ts:23850 >>>>>>> upstream/master
|
||||
#
|
||||
# from an upstream merge nobody finished, sitting in the shipped package.
|
||||
#
|
||||
# The compilers are no help, which is the whole problem. In TypeScript the
|
||||
# markers are a syntax error — but a .d.ts nothing imports is never parsed, so
|
||||
# there is no error to see. In Go they are a parse error only in a file the
|
||||
# build reaches. In YAML, JSON, Markdown, SQL, HTML and every config format we
|
||||
# ship, they are silently VALID content: a marker in a values file is a key
|
||||
# nobody notices, and a marker in a Markdown doc renders as text.
|
||||
#
|
||||
# So the defect class is "both sides of a merge shipped, and nothing in the
|
||||
# pipeline had an opinion". That is the same shape as its two siblings in this
|
||||
# step — a declaration and reality disagreeing, with no reader positioned to
|
||||
# notice — which is why it belongs here rather than in any one repo's gate.
|
||||
#
|
||||
# WHAT IT LOOKS FOR, AND WHY NOT MORE
|
||||
#
|
||||
# Only the two LABELLED markers git actually writes:
|
||||
#
|
||||
# ^<<<<<<< <label> the start marker, seven '<' then a space
|
||||
# ^======= the divider, seven bare equals
|
||||
# ^>>>>>>> <label> the end marker, seven '>' then a space
|
||||
#
|
||||
# ALL THREE, IN THAT ORDER, EACH ON ITS OWN LINE. Not any one of them — the
|
||||
# whole shape. That is not belt-and-braces, it is the difference between a gate
|
||||
# and a nuisance, and it was measured the expensive way: matching the start
|
||||
# marker ALONE failed hanzoai/app, whose builder tests carry
|
||||
#
|
||||
# <<<<<<< START_TITLE index.html >>>>>>> END_TITLE
|
||||
#
|
||||
# as literal fixture data. That line opens with seven '<' and a space and is
|
||||
# not a merge conflict — both markers sit on ONE line, which is a thing git
|
||||
# never writes. A start marker that also carries an end marker is therefore
|
||||
# excluded outright, and a file must show the divider and a separate end line
|
||||
# before this refuses it.
|
||||
#
|
||||
# The divider is only ever matched as part of that conjunction, never alone:
|
||||
# seven bare equals signs at column 1 is also how reStructuredText and Setext
|
||||
# Markdown underline a heading, and failing every doc in the estate is how a
|
||||
# gate gets switched off.
|
||||
#
|
||||
# Exactly seven, anchored at column 1: `<<<<<<<<` (eight — a heredoc, a C++
|
||||
# stream) does not match, and neither does an indented mention inside a comment
|
||||
# explaining conflict markers, which is what lets this file describe them
|
||||
# without flagging itself.
|
||||
#
|
||||
# Tracked files only, via `git grep`, so a stray marker in an untracked scratch
|
||||
# file or in node_modules cannot fail a build. `-I` skips binaries.
|
||||
set -euo pipefail
|
||||
|
||||
cd "${1:-.}"
|
||||
|
||||
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || {
|
||||
echo "conflictmarkers: not a git work tree ($(pwd)) — skipping"; exit 0; }
|
||||
|
||||
# Built from variables rather than written literally, so this script is not its
|
||||
# own first finding when it scans the repo that carries it.
|
||||
L=$(printf '<%.0s' $(seq 7))
|
||||
E=$(printf '=%.0s' $(seq 7))
|
||||
R=$(printf '>%.0s' $(seq 7))
|
||||
|
||||
# Candidate files: those carrying a start marker that is NOT also an end marker
|
||||
# on the same line. `-l` for names only; the line numbers come later, per file.
|
||||
cands=$(git grep -I -l -E "^${L} " -- . || true)
|
||||
|
||||
bad=""
|
||||
for f in $cands; do
|
||||
grep -qE "^${L} " -- "$f" 2>/dev/null || continue
|
||||
# The same-line form (app's fixture) is not a conflict. Require at least one
|
||||
# start line that does not also carry an end marker.
|
||||
grep -E "^${L} " -- "$f" 2>/dev/null | grep -qv -- "$R" || continue
|
||||
# And require the rest of the shape: a bare divider and a separate end line.
|
||||
grep -qE "^${E}$" -- "$f" 2>/dev/null || continue
|
||||
grep -E "^${R} " -- "$f" 2>/dev/null | grep -qv -- "$L" || continue
|
||||
bad="$bad $f"
|
||||
done
|
||||
|
||||
if [ -n "$bad" ]; then
|
||||
echo "::error::committed merge-conflict markers — an unresolved merge is in the tree"
|
||||
for f in $bad; do
|
||||
grep -nE "^(${L} |${E}$|${R} )" -- "$f" | while IFS= read -r line; do
|
||||
echo " $f:$line"
|
||||
done
|
||||
echo "::error file=${f}::committed conflict marker"
|
||||
done
|
||||
echo
|
||||
echo "Resolve the merge and commit the result. If the file is GENERATED, do not"
|
||||
echo "hand-edit it — re-run its generator and commit that."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "OK: no committed conflict markers ($(git ls-files | wc -l) tracked files)"
|
||||
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env bash
|
||||
# gover — refuse a Dockerfile whose Go builder image is older than the module
|
||||
# it compiles. One implementation, every caller.
|
||||
#
|
||||
# gover <dockerfile> [context-dir] # e.g. gover Dockerfile .
|
||||
#
|
||||
# WHAT THIS CATCHES, AND WHY IT IS A GATE AND NOT A CONVENTION
|
||||
#
|
||||
# The official `golang` images set GOTOOLCHAIN=local. That is deliberate on
|
||||
# their part — the image promises the Go it ships and refuses to silently
|
||||
# fetch another. The consequence is that a go.mod requiring a NEWER Go than
|
||||
# the base image does not degrade, it dies:
|
||||
#
|
||||
# go: go.mod requires go >= 1.26.5 (running go 1.26.4; GOTOOLCHAIN=local)
|
||||
#
|
||||
# hanzoai/visor v1.108.16 is the shipped instance. The failure is invisible
|
||||
# until the image build runs, because every local `go build` succeeds: a
|
||||
# developer box runs GOTOOLCHAIN=auto and simply downloads what the module
|
||||
# asks for. So the mismatch is introduced by editing go.mod — a file that has
|
||||
# nothing to do with Docker — and is discovered by a red release build.
|
||||
#
|
||||
# It is also not one repo's problem. A sweep of every Dockerfile across the
|
||||
# orgs found 58 FROM lines in 23 repos already below their own go.mod, and
|
||||
# only 7 of 223 Go builder stages set GOTOOLCHAIN=auto. Fixing those 58 fixes
|
||||
# today; this gate is what makes the 59th impossible, which is the part worth
|
||||
# having.
|
||||
#
|
||||
# WHAT IT DELIBERATELY DOES NOT DO
|
||||
#
|
||||
# It does not require the newest Go, and it does not care that an image is
|
||||
# behind `latest`. A newer toolchain compiling an older `go` directive is
|
||||
# always valid, so pinning ahead of go.mod is fine and stays silent. The only
|
||||
# thing refused is an image BELOW the module's floor, because that is the only
|
||||
# arrangement that cannot build.
|
||||
#
|
||||
# A floating tag (`golang:1.26-alpine`, no patch) resolves to the newest patch
|
||||
# of that minor when the image is pulled. Against a patch-pinned go.mod that
|
||||
# is correct today and fragile tomorrow — a stale registry mirror serves an
|
||||
# older patch and the build dies with the message above. That earns a warning,
|
||||
# never a failure: it builds, and a gate that fails what builds trains people
|
||||
# to skip gates.
|
||||
#
|
||||
# EXIT: 0 clean (warnings still print), 1 when a stage cannot build.
|
||||
set -uo pipefail
|
||||
|
||||
df=${1:?usage: gover <dockerfile> [context-dir]}
|
||||
ctx=${2:-.}
|
||||
[ -f "$df" ] || { echo "gover: no such Dockerfile: $df" >&2; exit 1; }
|
||||
|
||||
# --- the module floor -------------------------------------------------------
|
||||
# Nearest go.mod walking up from the Dockerfile, then the build context, then
|
||||
# the repo root. Multi-module repos are the reason this walks rather than
|
||||
# assuming the root: hanzoai/s3 builds s3-rdma-sidecar/ and telemetry/server/
|
||||
# from their own go.mod files, each with a different floor.
|
||||
#
|
||||
# WHICH go.mod, precisely: the BUILD CONTEXT decides, not where the file sits.
|
||||
# A Dockerfile under test/kafka/ built with `context: ../..` compiles the ROOT
|
||||
# module, and judging it by test/kafka/go.mod reads the wrong floor — in
|
||||
# hanzoai/s3 that difference is 1.25.0 vs 1.26.5, i.e. the difference between
|
||||
# "fine" and "cannot build". So when a Dockerfile copies the context's own
|
||||
# go.mod (`COPY go.mod ...`, the overwhelmingly common shape), that is the
|
||||
# module being compiled and the context's go.mod wins.
|
||||
#
|
||||
# Otherwise fall back to the nearest go.mod above the Dockerfile, which is the
|
||||
# right answer for a subdirectory that is its own module and is built with its
|
||||
# own directory as the context — hanzoai/s3's s3-rdma-sidecar/ and
|
||||
# telemetry/server/ are both that shape.
|
||||
govers=""; gosrc=""
|
||||
if grep -qiE '^[[:space:]]*COPY([[:space:]]+--[^[:space:]]+)*[[:space:]]+([^[:space:]]+[[:space:]]+)*go\.mod([[:space:]]|$)' "$df" 2>/dev/null \
|
||||
&& [ -f "$ctx/go.mod" ]; then
|
||||
gosrc="$ctx/go.mod"
|
||||
fi
|
||||
if [ -z "$gosrc" ]; then
|
||||
d=$(dirname "$df")
|
||||
while :; do
|
||||
if [ -f "$d/go.mod" ]; then gosrc="$d/go.mod"; break; fi
|
||||
[ "$d" = "." ] || [ "$d" = "/" ] || [ -z "$d" ] && break
|
||||
d=$(dirname "$d")
|
||||
done
|
||||
fi
|
||||
[ -z "$gosrc" ] && [ -f "$ctx/go.mod" ] && gosrc="$ctx/go.mod"
|
||||
[ -z "$gosrc" ] && [ -f "go.mod" ] && gosrc="go.mod"
|
||||
# No module in play — nothing to compare against, and a non-Go image is not
|
||||
# this gate's business.
|
||||
[ -z "$gosrc" ] && exit 0
|
||||
govers=$(grep -m1 -E '^go[[:space:]]+[0-9]' "$gosrc" 2>/dev/null | awk '{print $2}')
|
||||
[ -z "$govers" ] && exit 0
|
||||
|
||||
# --- ARG defaults -----------------------------------------------------------
|
||||
# `FROM golang:${GO_VERSION}-bookworm` is only as good as its default, and the
|
||||
# default is the value CI builds with unless hanzo.yml passes `args:`. Read
|
||||
# them so the parameterised Dockerfiles are checked too, not skipped.
|
||||
declare -A args=()
|
||||
while IFS= read -r line; do
|
||||
if [[ $line =~ ^[[:space:]]*[Aa][Rr][Gg][[:space:]]+([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then
|
||||
v="${BASH_REMATCH[2]}"
|
||||
v="${v%%#*}" # strip trailing comment
|
||||
v="${v//\"/}"; v="${v//\'/}" # strip quotes
|
||||
v="${v#"${v%%[![:space:]]*}"}"; v="${v%"${v##*[![:space:]]}"}"
|
||||
args[${BASH_REMATCH[1]}]="$v"
|
||||
fi
|
||||
done < "$df"
|
||||
|
||||
# semver -> comparable integer; missing patch becomes -1 so a floating tag is
|
||||
# distinguishable from an explicit .0 rather than silently equal to it.
|
||||
num() { # num <major> <minor> <patch|-1>
|
||||
printf '%d' $(( $1 * 1000000 + $2 * 1000 + ($3 < 0 ? 999 : $3) ))
|
||||
}
|
||||
parse() { # parse <version-ish> -> "major minor patch"; empty when unparseable
|
||||
local v=$1
|
||||
[[ $v =~ ^([0-9]+)\.([0-9]+)\.([0-9]+) ]] && { echo "${BASH_REMATCH[1]} ${BASH_REMATCH[2]} ${BASH_REMATCH[3]}"; return; }
|
||||
[[ $v =~ ^([0-9]+)\.([0-9]+) ]] && { echo "${BASH_REMATCH[1]} ${BASH_REMATCH[2]} -1"; return; }
|
||||
echo ""
|
||||
}
|
||||
|
||||
read -r mM mm mp <<<"$(parse "$govers")"
|
||||
[ -z "${mM:-}" ] && exit 0 # go.mod says something we cannot read; not our call
|
||||
|
||||
rc=0; n=0
|
||||
while IFS= read -r line; do
|
||||
# FROM [--flag ...] [registry/]golang:<tag> [AS stage]
|
||||
[[ $line =~ ^[[:space:]]*[Ff][Rr][Oo][Mm][[:space:]]+(--[^[:space:]]+[[:space:]]+)*([^[:space:]]*golang:[^[:space:]]+) ]] || continue
|
||||
ref="${BASH_REMATCH[2]}"
|
||||
tag="${ref##*golang:}"
|
||||
# resolve ${VAR} / $VAR against the ARG defaults
|
||||
while [[ $tag =~ \$\{?([A-Za-z_][A-Za-z0-9_]*)\}? ]]; do
|
||||
name="${BASH_REMATCH[1]}"; sub="${args[$name]:-}"
|
||||
[ -z "$sub" ] && { tag=""; break; }
|
||||
tag="${tag//\$\{$name\}/$sub}"; tag="${tag//\$$name/$sub}"
|
||||
done
|
||||
[ -z "$tag" ] && continue
|
||||
n=$((n+1))
|
||||
read -r iM im ip <<<"$(parse "$tag")"
|
||||
# `golang:alpine`, `golang:1-alpine`, `ARG GO_VERSION=INVALID` — no version to
|
||||
# compare. Say so once; do not guess and do not fail.
|
||||
[ -z "${iM:-}" ] && { echo "gover: $df: '$ref' names no Go version — cannot check it against $gosrc ($govers)"; continue; }
|
||||
if [ "$(num "$iM" "$im" "$ip")" -lt "$(num "$mM" "$mm" "$mp")" ]; then
|
||||
echo "::error file=$df::Go builder image is older than the module it builds: '$ref' provides Go $iM.$im${ip:+.$ip} but $gosrc requires go $govers. The golang images set GOTOOLCHAIN=local, so this build fails with 'go.mod requires go >= $govers'. Fix: pin the base to golang:$govers-<variant>, and add 'ENV GOTOOLCHAIN=auto' to the builder stage so a future go.mod bump downloads the toolchain instead of failing."
|
||||
rc=1
|
||||
elif [ "$ip" -lt 0 ] && [ "$mp" -ge 0 ]; then
|
||||
echo "::warning file=$df::'$ref' floats to the newest patch of $iM.$im, while $gosrc pins go $govers. It builds today and fails the moment a registry mirror serves an older patch. Pin golang:$govers-<variant> to make it hermetic."
|
||||
fi
|
||||
done < "$df"
|
||||
|
||||
[ "$n" = 0 ] && exit 0
|
||||
[ "$rc" = 0 ] && echo "gover: OK — $df ($n Go stage$([ "$n" = 1 ] || echo s)) satisfies $gosrc (go $govers)"
|
||||
exit $rc
|
||||
Executable
+128
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env bash
|
||||
# Tests for bin/gover. Offline and deterministic: every case is a Dockerfile and
|
||||
# a go.mod written into a temp dir, so this needs no registry and no network.
|
||||
# Run: bash bin/gover_test.sh
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
GOVER="$PWD/bin/gover"
|
||||
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
|
||||
fail=0
|
||||
|
||||
# t <name> <want-rc> <go.mod-directive> <dockerfile-body...>
|
||||
t() {
|
||||
local name=$1 want=$2 gomod=$3; shift 3
|
||||
local d="$tmp/$RANDOM$RANDOM"; mkdir -p "$d"
|
||||
printf 'module x\n\ngo %s\n' "$gomod" > "$d/go.mod"
|
||||
printf '%s\n' "$@" > "$d/Dockerfile"
|
||||
out=$(cd "$d" && bash "$GOVER" Dockerfile . 2>&1); rc=$?
|
||||
if [ "$rc" = "$want" ]; then printf 'ok %-56s rc=%s\n' "$name" "$rc"
|
||||
else printf 'FAIL %-56s rc=%s (want %s)\n %s\n' "$name" "$rc" "$want" "$out"; fail=1; fi
|
||||
}
|
||||
# grep-based assertion for the message body, not just the code
|
||||
tmsg() {
|
||||
local name=$1 pat=$2 gomod=$3; shift 3
|
||||
local d="$tmp/$RANDOM$RANDOM"; mkdir -p "$d"
|
||||
printf 'module x\n\ngo %s\n' "$gomod" > "$d/go.mod"
|
||||
printf '%s\n' "$@" > "$d/Dockerfile"
|
||||
out=$(cd "$d" && bash "$GOVER" Dockerfile . 2>&1)
|
||||
if printf '%s' "$out" | grep -q "$pat"; then printf 'ok %-56s\n' "$name"
|
||||
else printf 'FAIL %-56s\n got: %s\n' "$name" "$out"; fail=1; fi
|
||||
}
|
||||
|
||||
# --- the refusal: image below the module floor ------------------------------
|
||||
# This is the visor v1.108.16 shape exactly.
|
||||
t "patch below floor is refused" 1 1.26.5 'FROM golang:1.26.4-alpine'
|
||||
t "minor below floor is refused" 1 1.26.5 'FROM golang:1.25-alpine'
|
||||
t "ancient relic is refused" 1 1.26.4 'FROM golang:1.10.1'
|
||||
t "second stage is checked too" 1 1.26.5 'FROM node:22 AS web' 'FROM golang:1.26.1-bookworm AS api'
|
||||
|
||||
# --- the allowances ---------------------------------------------------------
|
||||
t "exact match builds" 0 1.26.5 'FROM golang:1.26.5-alpine'
|
||||
t "newer image than floor is fine" 0 1.26.4 'FROM golang:1.26.5-alpine'
|
||||
t "much newer image is fine" 0 1.25.0 'FROM golang:1.26.5-bookworm'
|
||||
t "alpine suffix is not a Go patch" 0 1.26 'FROM golang:1.26-alpine3.24'
|
||||
t "registry prefix is stripped" 0 1.26.5 'FROM docker.io/library/golang:1.26.5-alpine'
|
||||
t "--platform flag is skipped" 0 1.26.5 'FROM --platform=$BUILDPLATFORM golang:1.26.5-alpine AS b'
|
||||
t "non-Go image is not our business" 0 1.26.5 'FROM alpine:3.21'
|
||||
|
||||
# --- floating tags warn, never fail -----------------------------------------
|
||||
# They build. A gate that fails what builds trains people to skip gates.
|
||||
t "floating tag does not fail" 0 1.26.5 'FROM golang:1.26-alpine'
|
||||
tmsg "floating tag warns" '::warning' 1.26.5 'FROM golang:1.26-alpine'
|
||||
t "floating minor below floor IS refused" 1 1.26.5 'FROM golang:1.25-alpine'
|
||||
|
||||
# --- ARG resolution ---------------------------------------------------------
|
||||
# A parameterised FROM is only as good as its default; check it, don't skip it.
|
||||
t "ARG default below floor is refused" 1 1.26.5 'ARG GO_VERSION=1.26.4' 'FROM golang:${GO_VERSION}-bookworm'
|
||||
t "ARG default at floor builds" 0 1.26.5 'ARG GO_VERSION=1.26.5' 'FROM golang:${GO_VERSION}-bookworm'
|
||||
t "unbraced \$VAR resolves" 1 1.26.5 'ARG GO_VERSION=1.24' 'FROM golang:$GO_VERSION-bookworm'
|
||||
t "ARG with trailing comment parses" 1 1.26.5 'ARG GO_VERSION=1.26.4 # keep in step' 'FROM golang:${GO_VERSION}-alpine'
|
||||
# luxfi/node ships this literally, to silence a buildx warning on a Dockerfile
|
||||
# that is never built with the default. It must not crash the gate.
|
||||
t "unparseable ARG default is skipped" 0 1.26.5 'ARG GO_VERSION=INVALID # silences a warning' 'FROM golang:${GO_VERSION}-bookworm'
|
||||
t "unversioned golang:alpine is skipped" 0 1.26.5 'FROM golang:alpine'
|
||||
t "golang:1-alpine is skipped" 0 1.26.5 'FROM golang:1-alpine'
|
||||
|
||||
# --- module resolution ------------------------------------------------------
|
||||
# Multi-module repos build subdirectories against their OWN go.mod. Taking the
|
||||
# root's floor would report a mismatch that does not exist (or miss one that
|
||||
# does) — hanzoai/s3 is the live case.
|
||||
d="$tmp/multi"; mkdir -p "$d/sub"
|
||||
printf 'module root\n\ngo 1.26.5\n' > "$d/go.mod"
|
||||
printf 'module sub\n\ngo 1.24.0\n' > "$d/sub/go.mod"
|
||||
printf 'FROM golang:1.24-alpine\n' > "$d/sub/Dockerfile"
|
||||
out=$(cd "$d" && bash "$GOVER" sub/Dockerfile . 2>&1); rc=$?
|
||||
if [ "$rc" = 0 ]; then printf 'ok %-56s rc=0\n' "nearest go.mod wins over the root"
|
||||
else printf 'FAIL %-56s rc=%s\n %s\n' "nearest go.mod wins over the root" "$rc" "$out"; fail=1; fi
|
||||
printf 'FROM golang:1.24-alpine\n' > "$d/Dockerfile"
|
||||
out=$(cd "$d" && bash "$GOVER" Dockerfile . 2>&1); rc=$?
|
||||
if [ "$rc" = 1 ]; then printf 'ok %-56s rc=1\n' "root Dockerfile is judged by the root go.mod"
|
||||
else printf 'FAIL %-56s rc=%s\n %s\n' "root Dockerfile is judged by the root go.mod" "$rc" "$out"; fail=1; fi
|
||||
|
||||
# --- no module at all -------------------------------------------------------
|
||||
d2="$tmp/nomod"; mkdir -p "$d2"
|
||||
printf 'FROM golang:1.20-alpine\n' > "$d2/Dockerfile"
|
||||
out=$(cd "$d2" && bash "$GOVER" Dockerfile . 2>&1); rc=$?
|
||||
if [ "$rc" = 0 ]; then printf 'ok %-56s rc=0\n' "no go.mod: nothing to compare, stays silent"
|
||||
else printf 'FAIL %-56s rc=%s\n %s\n' "no go.mod: nothing to compare, stays silent" "$rc" "$out"; fail=1; fi
|
||||
|
||||
# --- the remediation is in the message --------------------------------------
|
||||
# A gate that says only "no" costs the next person the same hour it cost the
|
||||
# last one.
|
||||
tmsg "error names the fix (pin + GOTOOLCHAIN=auto)" 'GOTOOLCHAIN=auto' 1.26.5 'FROM golang:1.26.4-alpine'
|
||||
tmsg "error quotes the runtime failure it prevents" 'go.mod requires go >=' 1.26.5 'FROM golang:1.26.4-alpine'
|
||||
|
||||
# --- build context decides the module, not file location -------------------
|
||||
# hanzoai/s3's live shape: a Dockerfile under test/kafka/ built with
|
||||
# `context: ../..` that does `COPY go.mod go.sum ./` compiles the ROOT module.
|
||||
# Judging it by test/kafka/go.mod reads 1.25.0 where the truth is 1.26.5 — the
|
||||
# difference between "fine" and "cannot build".
|
||||
d3="$tmp/ctxwins"; mkdir -p "$d3/test/kafka"
|
||||
printf 'module root\n\ngo 1.26.5\n' > "$d3/go.mod"
|
||||
printf 'module sub\n\ngo 1.25.0\n' > "$d3/test/kafka/go.mod"
|
||||
printf 'FROM golang:1.25-alpine\nCOPY go.mod go.sum ./\nRUN go build ./...\n' > "$d3/test/kafka/Dockerfile.s3"
|
||||
out=$(cd "$d3" && bash "$GOVER" test/kafka/Dockerfile.s3 . 2>&1); rc=$?
|
||||
if [ "$rc" = 1 ]; then printf 'ok %-56s rc=1\n' "context go.mod wins when Dockerfile COPYs it"
|
||||
else printf 'FAIL %-56s rc=%s\n %s\n' "context go.mod wins when Dockerfile COPYs it" "$rc" "$out"; fail=1; fi
|
||||
# ...and the converse: a subdir module built from its OWN directory as context,
|
||||
# copying its OWN go.mod, is still judged by its own floor.
|
||||
d4="$tmp/subctx"; mkdir -p "$d4/sidecar"
|
||||
printf 'module root\n\ngo 1.26.5\n' > "$d4/go.mod"
|
||||
printf 'module sidecar\n\ngo 1.24.0\n' > "$d4/sidecar/go.mod"
|
||||
printf 'FROM golang:1.24-alpine\nCOPY go.mod go.sum ./\n' > "$d4/sidecar/Dockerfile"
|
||||
out=$(cd "$d4/sidecar" && bash "$GOVER" Dockerfile . 2>&1); rc=$?
|
||||
if [ "$rc" = 0 ]; then printf 'ok %-56s rc=0\n' "own-directory context keeps its own floor"
|
||||
else printf 'FAIL %-56s rc=%s\n %s\n' "own-directory context keeps its own floor" "$rc" "$out"; fail=1; fi
|
||||
# A Dockerfile that copies a SUBDIRECTORY's go.mod is judged by the nearest one,
|
||||
# not the context root — otherwise every multi-module repo reports false alarms.
|
||||
d5="$tmp/nocopy"; mkdir -p "$d5/svc"
|
||||
printf 'module root\n\ngo 1.26.5\n' > "$d5/go.mod"
|
||||
printf 'module svc\n\ngo 1.24.0\n' > "$d5/svc/go.mod"
|
||||
printf 'FROM golang:1.24-alpine\nCOPY svc/go.mod ./\n' > "$d5/svc/Dockerfile"
|
||||
out=$(cd "$d5" && bash "$GOVER" svc/Dockerfile . 2>&1); rc=$?
|
||||
if [ "$rc" = 0 ]; then printf 'ok %-56s rc=0\n' "subdir go.mod copy is not the context root"
|
||||
else printf 'FAIL %-56s rc=%s\n %s\n' "subdir go.mod copy is not the context root" "$rc" "$out"; fail=1; fi
|
||||
|
||||
echo
|
||||
[ $fail = 0 ] && echo "all gover tests passed" || echo "gover tests FAILED"
|
||||
exit $fail
|
||||
Executable
+118
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env bash
|
||||
# ignoretracked — refuse a repo that ships bulk content its own .gitignore
|
||||
# claims to be ignoring. One implementation, every caller.
|
||||
#
|
||||
# ignoretracked [dir]
|
||||
#
|
||||
# WHAT THIS CATCHES
|
||||
#
|
||||
# An ignore rule does not untrack what is already tracked. `.gitignore` is
|
||||
# consulted when git decides whether to ADD an untracked path; a path already
|
||||
# in the index is never reconsidered. So this arrangement is stable and silent:
|
||||
#
|
||||
# .gitignore says native/flags/target/
|
||||
# the index says 855 files under native/flags/target/
|
||||
# git status says nothing
|
||||
#
|
||||
# hanzoai/cloud lived there for 6 days and 16 hours. 362 MB of orphaned cargo
|
||||
# output — three near-identical 37.8 MB staticlibs and a pile of .rlib — went
|
||||
# into every release, took the module past Go's 500 MiB ceiling (see
|
||||
# bin/modsize) and made it UNFETCHABLE for nine consecutive releases. The
|
||||
# `git archive` of that commit is 202 MB against 12.5 MB today: 16x.
|
||||
#
|
||||
# The causal story is worth knowing because it is not carelessness, it is a
|
||||
# race nobody could see. The ignore rule was REMOVED at 12:22 (the Rust
|
||||
# staticlib was being dropped for a Go evaluator), the 855 files were added at
|
||||
# 12:51 inside that 4.5-hour window when nothing was ignoring them, and the
|
||||
# rule was RE-ADDED at 16:48. Every individual step was reasonable. The state
|
||||
# they combined into is the defect, and no tool in the pipeline had an opinion
|
||||
# about it.
|
||||
#
|
||||
# WHY THIS GATES ON BYTES AND NOT ON PRESENCE
|
||||
#
|
||||
# The obvious gate — "any tracked path matched by .gitignore fails" — is
|
||||
# correct in principle and unshippable in fact. Measured across all 845 git
|
||||
# repos in the three orgs: 155 of them (18%) carry at least one tracked file
|
||||
# their .gitignore matches, and hanzoai/cloud's own origin/main is one of them
|
||||
# (a bare, unanchored `tools` pattern on .gitignore:62 catches 27 legitimately
|
||||
# tracked Go source files under apps/tools/). `CLAUDE.md` alone is ignored-and-
|
||||
# tracked in 40 repos and is the ONLY hit in 29 of them. Turning that on as a
|
||||
# hard fail breaks a fifth of the estate on day one, and a gate that fails what
|
||||
# ought to pass is a gate that gets switched off — after which we are worse off
|
||||
# than before it existed.
|
||||
#
|
||||
# So the gate is on the quantity that actually caused the outage. The byte
|
||||
# distribution separates cleanly, which is why this threshold can be a refusal
|
||||
# rather than a warning:
|
||||
#
|
||||
# worst legitimate repo in the estate 61.5 MB (hanzo/docs, vendored)
|
||||
# ... next 48.8 MB, 45.8, 45.2, 43.8, 39.8
|
||||
# repos over 100 MB ZERO
|
||||
# the defect this gate exists for 362.0 MB
|
||||
#
|
||||
# Default ceiling 100 MB: green on all 845 repos today with 62% headroom over
|
||||
# the worst honest case, and red on the real defect by 3.6x. Nothing to
|
||||
# baseline, no allowlist, no per-repo exemptions — the tail is REPORTED (so the
|
||||
# hygiene problem stays visible and shrinkable) and only the bulk is REFUSED.
|
||||
#
|
||||
# As repos are cleaned up, lower IGNORETRACKED_MAX_MB. It is a ratchet, and the
|
||||
# end state is 0 — at which point this becomes the pure presence gate that was
|
||||
# right all along. Do not start there.
|
||||
#
|
||||
# WHY core.excludesFile IS PINNED OFF
|
||||
#
|
||||
# `--exclude-standard` reads THREE sources: the repo's committed .gitignore
|
||||
# files, .git/info/exclude, and the user's global core.excludesFile. The last
|
||||
# is per-machine, so the same commit gets different verdicts on a laptop and a
|
||||
# runner — on this workstation a global bare `tags` pattern matched 1,271
|
||||
# Elixir source files in lux/explorer-v1 that CI would never flag. A gate whose
|
||||
# answer depends on whose machine asked is not a gate. Only committed
|
||||
# .gitignore is in scope, so the global file is pinned to /dev/null.
|
||||
# (.git/info/exclude is per-clone and is the stock all-comments template on a
|
||||
# fresh CI checkout; it cannot be overridden by -c, and is left alone.)
|
||||
#
|
||||
# EXIT: 0 clean or under the ceiling (findings still reported), 1 over it.
|
||||
set -uo pipefail
|
||||
|
||||
MAX_MB=${IGNORETRACKED_MAX_MB:-100}
|
||||
root=${1:-.}
|
||||
cd "$root" 2>/dev/null || { echo "ignoretracked: no such directory: $root" >&2; exit 1; }
|
||||
git rev-parse --git-dir >/dev/null 2>&1 || exit 0 # not a repo: nothing to check
|
||||
|
||||
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
|
||||
|
||||
# -s gives "<mode> <sha> <stage>\t<path>", which carries the blob id — so the
|
||||
# size comes from cat-file and needs no working-tree stat (correct on a bare or
|
||||
# sparse checkout, and unaffected by anything a build has written).
|
||||
git -c core.excludesFile=/dev/null ls-files -s --cached --ignored --exclude-standard \
|
||||
> "$tmp/ls" 2>/dev/null || exit 0
|
||||
[ -s "$tmp/ls" ] || { echo "ignoretracked: OK — nothing tracked that .gitignore ignores"; exit 0; }
|
||||
|
||||
files=$(wc -l < "$tmp/ls")
|
||||
bytes=$(awk '{print $2}' "$tmp/ls" \
|
||||
| git cat-file --batch-check='%(objectsize)' --buffer 2>/dev/null \
|
||||
| awk '/^[0-9]+$/{s+=$1} END{print s+0}')
|
||||
mb=$(awk -v b="${bytes:-0}" 'BEGIN{printf "%.1f", b/1048576}')
|
||||
|
||||
# WHICH .gitignore LINE is doing this. Plain `check-ignore` prints nothing for a
|
||||
# tracked path (it answers "would git ignore this if it were untracked", and a
|
||||
# tracked path short-circuits) — --no-index is what makes it answer for the
|
||||
# files we actually have. The output names source:line:pattern, so the report
|
||||
# points at the line to edit instead of at 855 paths to read.
|
||||
cut -f2- "$tmp/ls" | git -c core.excludesFile=/dev/null check-ignore --no-index -v --stdin \
|
||||
> "$tmp/why" 2>/dev/null || true
|
||||
|
||||
echo "ignoretracked: ${files} tracked file(s), ${mb} MB, matched by this repo's own .gitignore"
|
||||
if [ -s "$tmp/why" ]; then
|
||||
echo " by rule (top 10):"
|
||||
awk -F'\t' '{n[$1]++} END{for (k in n) printf "%8d %s\n", n[k], k}' "$tmp/why" \
|
||||
| sort -rn | head -10
|
||||
fi
|
||||
|
||||
limit=$(( MAX_MB * 1048576 ))
|
||||
if [ "${bytes:-0}" -ge "$limit" ]; then
|
||||
echo "::error::${mb} MB of tracked content is matched by this repo's own .gitignore — over the ${MAX_MB} MB ceiling. An ignore rule does not untrack what is already tracked, so this ships in every release and every module zip while \`git status\` stays clean. Fix: \`git rm -r --cached <path>\` and commit (the working tree is untouched). If the content is meant to be tracked, un-ignore it instead — negate the rule with \`!<path>\` — so the two stop disagreeing."
|
||||
exit 1
|
||||
fi
|
||||
echo "ignoretracked: OK — ${mb} MB is under the ${MAX_MB} MB ceiling (reported, not blocking)"
|
||||
exit 0
|
||||
Executable
+93
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env bash
|
||||
# imgver — the version an image build publishes. One implementation, every caller.
|
||||
#
|
||||
# imgver <image-repo> [context-dir] # e.g. imgver ghcr.io/hanzoai/iam .
|
||||
#
|
||||
# We don't ship shas. A build that cannot name a version is a broken build, so
|
||||
# this exits non-zero rather than letting a caller fall back to sha-<short>.
|
||||
#
|
||||
# WHY THIS IS A SCRIPT AND NOT INLINE SHELL: the fleet has two build front doors
|
||||
# — hanzoai/ci's build.yml (imported by repos with a hanzo.yml) and the
|
||||
# hand-rolled .hanzo/workflows/deploy.yml that 11 repos carry instead. Both need
|
||||
# the identical number. Written twice it would be right twice and then wrong
|
||||
# once, which is how `sha-<short>` became the only tag those 11 repos ever
|
||||
# published.
|
||||
#
|
||||
# THE NUMBER IS DERIVED, NEVER TYPED, and monotonic against two floors:
|
||||
# declared — the repo's own manifest (package.json / Cargo.toml / VERSION /
|
||||
# pyproject.toml, or $IMGVER_VERSION to name it outright). The
|
||||
# human's say: bump the minor there and the series jumps there.
|
||||
# published — the highest semver already at the registry for THIS image.
|
||||
# max(declared, published) + a patch is what stops one name from ever covering
|
||||
# two digests. Deriving from the manifest alone re-publishes the same number on
|
||||
# every push until someone edits the file, and a node running
|
||||
# imagePullPolicy: IfNotPresent never picks up the second one. universe's
|
||||
# images.yml learned that on iam-secret-sync; this is that rule, everywhere.
|
||||
#
|
||||
# ENV: GH_PAT (or GITHUB_TOKEN) to read the registry floor; IMGVER_PUBLISHED to
|
||||
# supply it directly (a registry this cannot read, and the test seam).
|
||||
# Without either, only the manifest carries the series.
|
||||
set -euo pipefail
|
||||
|
||||
repo="${1:?usage: imgver <image-repo> [context-dir]}"
|
||||
ctx="${2:-.}"
|
||||
|
||||
semver='^[0-9]+\.[0-9]+\.[0-9]+$'
|
||||
|
||||
# ---- declared ---------------------------------------------------------------
|
||||
declared="${IMGVER_VERSION:-}"
|
||||
case "$declared" in
|
||||
# The "<file>:<command printing it>" shape hanzo.yml's client lane already uses.
|
||||
*:*) declared=$(bash -c "${declared#*:}" 2>/dev/null || true) ;;
|
||||
esac
|
||||
if [ -z "$declared" ]; then
|
||||
# The image's own context first, then the repo root: a monorepo's web/ or api/
|
||||
# carries the version of the thing being built, not the workspace stub.
|
||||
for d in "$ctx" .; do
|
||||
[ -d "$d" ] || continue
|
||||
if [ -f "$d/package.json" ]; then
|
||||
declared=$(jq -r '.version // ""' "$d/package.json" 2>/dev/null || true)
|
||||
elif [ -f "$d/Cargo.toml" ]; then
|
||||
declared=$(sed -n '/^\[\(workspace\.\)\?package\]/,/^\[/p' "$d/Cargo.toml" \
|
||||
| sed -n 's/^version *= *"\([^"]*\)".*/\1/p' | head -1)
|
||||
elif [ -f "$d/VERSION" ]; then
|
||||
declared=$(tr -d ' \n' < "$d/VERSION")
|
||||
elif [ -f "$d/pyproject.toml" ]; then
|
||||
declared=$(sed -n 's/^version *= *"\([^"]*\)".*/\1/p' "$d/pyproject.toml" | head -1)
|
||||
fi
|
||||
[ -n "$declared" ] && break
|
||||
done
|
||||
fi
|
||||
declared="${declared#v}"
|
||||
# A workspace stub (0.0.0) or a placeholder is not a version anyone declared.
|
||||
[ "$declared" = "0.0.0" ] && declared=""
|
||||
echo "$declared" | grep -qE "$semver" || declared=""
|
||||
|
||||
# ---- published --------------------------------------------------------------
|
||||
# The GitHub Packages API, not the registry v2 tags list: an anonymous ghcr pull
|
||||
# token can fetch a manifest by name but returns an EMPTY tag list, so a v2 read
|
||||
# would silently report "nothing published" and restart the series at 0.
|
||||
published="${IMGVER_PUBLISHED:-}"
|
||||
tok="${GH_PAT:-${GITHUB_TOKEN:-}}"
|
||||
if [ -z "$published" ] && [ -n "$tok" ] && [ "${repo#ghcr.io/}" != "$repo" ]; then
|
||||
org="${repo#ghcr.io/}"; pkg="${org#*/}"; org="${org%%/*}"
|
||||
published=$(curl -fsSL -H "Authorization: Bearer $tok" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
"https://api.github.com/orgs/${org}/packages/container/${pkg}/versions?per_page=100" 2>/dev/null \
|
||||
| jq -r '.[].metadata.container.tags[]?' 2>/dev/null \
|
||||
| sed 's/^v//' | grep -E "$semver" | sort -V | tail -1 || true)
|
||||
fi
|
||||
|
||||
# ---- the number -------------------------------------------------------------
|
||||
max=$(printf '%s\n%s\n' "$declared" "$published" | grep -E "$semver" | sort -V | tail -1 || true)
|
||||
if [ -z "$max" ]; then
|
||||
echo "imgver: no version for $repo. Declare one — a package.json/Cargo.toml/VERSION/pyproject.toml under '$ctx', or IMGVER_VERSION. We don't ship shas." >&2
|
||||
exit 1
|
||||
elif [ "$max" = "$declared" ] && [ "$declared" != "$published" ]; then
|
||||
ver="$declared" # the human bumped it — publish exactly that
|
||||
else
|
||||
ver="${max%.*}.$(( ${max##*.} + 1 ))" # already out there — next patch
|
||||
fi
|
||||
|
||||
echo "imgver $repo: declared=${declared:-none} published=${published:-none} -> $ver" >&2
|
||||
echo "$ver"
|
||||
Executable
+60
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env bash
|
||||
# Tests for bin/imgver. Runs offline: no GH_PAT means no registry read, so the
|
||||
# published floor is injected through IMGVER_PUBLISHED and every case is
|
||||
# deterministic. Run: bash bin/imgver_test.sh
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
IMGVER="$PWD/bin/imgver"
|
||||
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
|
||||
fail=0
|
||||
|
||||
run() { # run <declared-env> <published> <ctx>
|
||||
IMGVER_VERSION="$1" IMGVER_PUBLISHED="$2" GH_PAT= GITHUB_TOKEN= \
|
||||
bash "$IMGVER" ghcr.io/hanzoai/test "$3" 2>/dev/null
|
||||
}
|
||||
t() { # t <name> <declared> <published> <ctx> <want>
|
||||
got=$(run "$2" "$3" "$4"); rc=$?
|
||||
[ $rc -ne 0 ] && got="ERROR"
|
||||
if [ "$got" = "$5" ]; then printf 'ok %-52s -> %s\n' "$1" "$got"
|
||||
else printf 'FAIL %-52s -> %s (want %s)\n' "$1" "$got" "$5"; fail=1; fi
|
||||
}
|
||||
|
||||
# --- the derivation ---------------------------------------------------------
|
||||
t "registry ahead: next patch, monotonic" 1.2.3 1.2.7 "$tmp" 1.2.8
|
||||
t "human bumped the minor: honour it verbatim" 1.3.0 1.2.8 "$tmp" 1.3.0
|
||||
t "same number published: never 2 digests/name" 1.2.3 1.2.3 "$tmp" 1.2.4
|
||||
t "no manifest version: registry carries it" "" 1.2.7 "$tmp" 1.2.8
|
||||
t "nothing published: seed at declared" 1.2.3 "" "$tmp" 1.2.3
|
||||
t "major bump honoured" 2.0.0 1.9.9 "$tmp" 2.0.0
|
||||
t "sort -V not lexical (1.2.10 > 1.2.9)" 1.2.9 1.2.10 "$tmp" 1.2.11
|
||||
t "cloud's real series" 1.801.341 1.801.341 "$tmp" 1.801.342
|
||||
t "stale manifest cannot drag series backwards" 0.0.1 0.9.0 "$tmp" 0.9.1
|
||||
t "no version anywhere: fail loud, never sha" "" "" "$tmp" ERROR
|
||||
t "leading v stripped" v1.4.0 "" "$tmp" 1.4.0
|
||||
t "0.0.0 workspace stub is not a version" 0.0.0 1.1.1 "$tmp" 1.1.2
|
||||
t "non-semver declared is ignored" "1.2" 2.0.0 "$tmp" 2.0.1
|
||||
|
||||
# --- manifest discovery ------------------------------------------------------
|
||||
m() { rm -rf "$tmp"/m; mkdir -p "$tmp"/m; }
|
||||
m; echo '{"version":"3.4.5"}' > "$tmp/m/package.json"
|
||||
t "package.json" "" "" "$tmp/m" 3.4.5
|
||||
m; printf '[package]\nname="x"\nversion = "6.7.8"\n' > "$tmp/m/Cargo.toml"
|
||||
t "Cargo.toml [package]" "" "" "$tmp/m" 6.7.8
|
||||
m; printf '[workspace.package]\nversion = "1.45.2"\n' > "$tmp/m/Cargo.toml"
|
||||
t "Cargo.toml [workspace.package] (index's shape)" "" "" "$tmp/m" 1.45.2
|
||||
m; echo "9.9.9" > "$tmp/m/VERSION"
|
||||
t "VERSION file" "" "" "$tmp/m" 9.9.9
|
||||
m; printf '[project]\nversion = "2.3.4"\n' > "$tmp/m/pyproject.toml"
|
||||
t "pyproject.toml" "" "" "$tmp/m" 2.3.4
|
||||
m; echo '{"name":"x"}' > "$tmp/m/package.json"
|
||||
t "package.json with no version key -> ERROR" "" "" "$tmp/m" ERROR
|
||||
m; echo '{"version":"0.0.0"}' > "$tmp/m/package.json"
|
||||
t "workspace stub package.json -> ERROR" "" "" "$tmp/m" ERROR
|
||||
m; echo '{"version":"1.0.0"}' > "$tmp/m/package.json"
|
||||
t "IMGVER_VERSION overrides the manifest" 5.5.5 "" "$tmp/m" 5.5.5
|
||||
m; echo '{"version":"1.0.0"}' > "$tmp/m/package.json"
|
||||
t "resolver expression <file>:<command>" "package.json:echo 7.7.7" "" "$tmp/m" 7.7.7
|
||||
|
||||
echo
|
||||
[ $fail -eq 0 ] && echo "imgver: all cases pass" || echo "imgver: FAILURES"
|
||||
exit $fail
|
||||
Executable
+123
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env bash
|
||||
# modsize — refuse a Go module that is approaching the size at which Go can no
|
||||
# longer fetch it. One implementation, every caller.
|
||||
#
|
||||
# modsize [dir] # default: every module in the repo, from the root
|
||||
#
|
||||
# WHAT THIS CATCHES
|
||||
#
|
||||
# Go's module ceiling is not a soft limit and not a warning. From
|
||||
# golang.org/x/mod/zip (and, identically, cmd/go/internal/modfetch/codehost):
|
||||
#
|
||||
# MaxZipFile = 500 << 20 // 524288000 bytes, 500 MiB
|
||||
#
|
||||
# and it is enforced THREE ways on the same number — the zip file itself, the
|
||||
# total uncompressed size of the files inside it, and (in CheckDir) the running
|
||||
# sum of the source tree's file sizes:
|
||||
#
|
||||
# "module source tree too large (max size is 524288000 bytes)"
|
||||
# "total uncompressed size of module contents too large (...)"
|
||||
#
|
||||
# hanzoai/cloud crossed it and became UNFETCHABLE for nine consecutive
|
||||
# releases. Nothing said so. The module publishes fine — the ceiling is
|
||||
# enforced on the CONSUMER, at `go get`, so the failure surfaces in someone
|
||||
# else's repo, at a version they did not choose, long after the release that
|
||||
# caused it. There is no signal at the publishing end at all, which is why nine
|
||||
# releases went out before anybody knew.
|
||||
#
|
||||
# WHY THIS SUMS RAW BYTES AND WHY THAT IS EXACT, NOT AN APPROXIMATION
|
||||
#
|
||||
# It would be reasonable to assume the limit is on the compressed zip and that
|
||||
# summing uncompressed bytes over-counts. It does not: zip.CheckDir accumulates
|
||||
# `info.Size()` — the raw, uncompressed size of each regular file — and fails
|
||||
# when that running total exceeds MaxZipFile. So the sum of file sizes IS one
|
||||
# of the three quantities Go bounds, and a git blob's size is exactly that
|
||||
# file's uncompressed size. This gate therefore measures the same number Go
|
||||
# measures, not a proxy for it.
|
||||
#
|
||||
# WHAT IT EXCLUDES, AND WHY EACH EXCLUSION IS REQUIRED FOR CORRECTNESS
|
||||
#
|
||||
# • Nested modules. A subdirectory with its own go.mod is a DIFFERENT module
|
||||
# and its bytes are not in the parent's zip. Counting them would fail a
|
||||
# repo that is nowhere near the limit — hanzoai/s3 is this shape (it builds
|
||||
# s3-rdma-sidecar/ and telemetry/server/ from their own go.mod files), and
|
||||
# a gate that fails a repo that would have worked is a gate people learn to
|
||||
# skip. Each nested module is instead checked on its own terms.
|
||||
# • Symlinks and gitlinks (submodules). zip.CheckDir skips both — symlinks
|
||||
# explicitly (golang.org/issue/27093), submodules because they are not
|
||||
# files. Counting a gitlink's 20-byte entry would be harmless; counting a
|
||||
# symlink's target as content would not.
|
||||
#
|
||||
# It reads tracked content only (git ls-tree), because that is what a module
|
||||
# zip is built from: the proxy serves what the VCS has at that tag, not what a
|
||||
# working tree happens to contain.
|
||||
#
|
||||
# THE THRESHOLD IS BELOW THE CEILING ON PURPOSE
|
||||
#
|
||||
# Failing AT 500 MiB would be useless — at that point the module is already
|
||||
# unfetchable and the only question left is how many releases shipped broken.
|
||||
# The gate refuses at MODSIZE_MAX_PCT (default 80%, ~419 MiB), which is the
|
||||
# "warn well before" — spelled as a refusal, because a warning about a cliff
|
||||
# nobody is watching is the same defect one level up. 105 MiB of headroom is
|
||||
# several releases' worth of honest growth, so this fires with room to fix it
|
||||
# calmly and never fires the release it would have broken.
|
||||
#
|
||||
# EXIT: 0 clean, 1 when a module is at or past the threshold.
|
||||
set -uo pipefail
|
||||
|
||||
# 500 << 20, from golang.org/x/mod/zip. Not a guess and not rounded: the
|
||||
# constant is quoted so a reader can check it against the source.
|
||||
CEILING=$((500 << 20))
|
||||
PCT=${MODSIZE_MAX_PCT:-80}
|
||||
REF=${MODSIZE_REF:-HEAD}
|
||||
|
||||
root=${1:-.}
|
||||
cd "$root" 2>/dev/null || { echo "modsize: no such directory: $root" >&2; exit 1; }
|
||||
git rev-parse --git-dir >/dev/null 2>&1 || exit 0 # not a repo: nothing to measure
|
||||
|
||||
# Every go.mod in the tree, at REF. The root module is "go.mod"; anything else
|
||||
# is a nested module and marks a prefix the parent must not count.
|
||||
mods=$(git ls-tree -r --name-only "$REF" 2>/dev/null | grep -E '(^|/)go\.mod$' | sort) || exit 0
|
||||
[ -z "$mods" ] && exit 0
|
||||
|
||||
# path<TAB>size for every regular tracked file. Mode 100644/100755 only:
|
||||
# 120000 is a symlink and 160000 a gitlink, both of which zip.CheckDir omits.
|
||||
# `git ls-tree -l` separates the path with a TAB, so split on that rather than
|
||||
# on whitespace: a path with spaces in it must survive intact.
|
||||
sizes=$(git ls-tree -r -l "$REF" 2>/dev/null \
|
||||
| sed -n 's/^\([0-9]\{6\}\) blob \([0-9a-f]*\) *\([0-9-]*\)\t\(.*\)$/\1\t\3\t\4/p' \
|
||||
| awk -F'\t' '$1=="100644"||$1=="100755"{print $3 "\t" $2}')
|
||||
|
||||
human() { awk -v b="$1" 'BEGIN{ printf "%.1f MiB", b/1048576 }'; }
|
||||
|
||||
rc=0
|
||||
for gm in $mods; do
|
||||
if [ "$gm" = "go.mod" ]; then mdir=""; else mdir="${gm%/go.mod}/"; fi
|
||||
# The prefixes this module must NOT count: every OTHER module nested under it.
|
||||
nested=$(printf '%s\n' "$mods" | while read -r o; do
|
||||
[ "$o" = "$gm" ] && continue
|
||||
od="${o%go.mod}"
|
||||
case "$od" in "$mdir"?*) printf '%s\n' "$od";; esac
|
||||
done)
|
||||
total=$(printf '%s\n' "$sizes" | awk -F'\t' -v m="$mdir" -v nl="$nested" '
|
||||
BEGIN{ n=split(nl, arr, "\n") }
|
||||
{
|
||||
p=$1
|
||||
if (m != "" && index(p, m) != 1) next # not in this module
|
||||
for (i=1; i<=n; i++) if (arr[i] != "" && index(p, arr[i]) == 1) next # nested module
|
||||
s += $2
|
||||
}
|
||||
END{ print s+0 }')
|
||||
limit=$(( CEILING * PCT / 100 ))
|
||||
name=${mdir:-./}
|
||||
if [ "$total" -ge "$CEILING" ]; then
|
||||
echo "::error file=${gm}::module ${name} is $(human "$total") of tracked content — PAST Go's $(human $CEILING) module ceiling. It is already unfetchable: \`go get\` fails with 'module source tree too large (max size is ${CEILING} bytes)' in every consumer, at every version that carries it. Find the bulk with: git ls-tree -r -l ${REF} | sort -k4 -n | tail -20"
|
||||
rc=1
|
||||
elif [ "$total" -ge "$limit" ]; then
|
||||
echo "::error file=${gm}::module ${name} is $(human "$total") of tracked content — ${PCT}% of Go's $(human $CEILING) ceiling, $(human $((CEILING-total))) of headroom left. Past the ceiling the module stops being fetchable AT THE CONSUMER, so the break shows up in someone else's build and not in this one. Find the bulk with: git ls-tree -r -l ${REF} | sort -k4 -n | tail -20"
|
||||
rc=1
|
||||
else
|
||||
echo "modsize: OK — ${name} $(human "$total") ($(( total * 100 / CEILING ))% of $(human $CEILING))"
|
||||
fi
|
||||
done
|
||||
exit $rc
|
||||
Executable
+46
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env bash
|
||||
# publishable — refuse a `build_secrets` name that does not declare itself public.
|
||||
#
|
||||
# Usage: publishable <hanzo.yml> (reads images[].build_secrets, rc=1 on refusal)
|
||||
#
|
||||
# WHY A NAME AND NOT A VALUE. A build_secret is handed to buildx as
|
||||
# `--build-arg NAME=value`, and a build-arg is IN THE PUBLISHED IMAGE: `docker
|
||||
# history` prints it to anyone who can pull. So the only value that may sit
|
||||
# here is one that is public on purpose — a Vite/Next static export has no
|
||||
# server to read an env from, so its ingest key must be inlined at build, and
|
||||
# inlining it is what publishing it means.
|
||||
#
|
||||
# The value cannot be the test. At the moment ci reads hanzo.yml the value does
|
||||
# not exist yet (KMS has not been called), and guessing secrecy from a string's
|
||||
# shape is a heuristic that is wrong in both directions. The name is what a
|
||||
# reviewer reads, it is in git, and it is decided by the person who knows the
|
||||
# answer. So the name carries the assertion.
|
||||
#
|
||||
# The fleet had already started saying it this way — hanzoai/docs renamed
|
||||
# EVENT_INGEST_KEY -> PUBLISHABLE_KEY, and hanzoai/world's four are VITE_*,
|
||||
# which a bundler inlines by construction. This turns that convention into the
|
||||
# mechanism. Before it, the ONE repo whose value is genuinely publishable
|
||||
# (hanzoai/ui, a `pk-…` key) asserted the prefix inside its own Dockerfile, so
|
||||
# the check existed once, for one image, and any repo adding a real credential
|
||||
# got no check at all.
|
||||
set -uo pipefail
|
||||
|
||||
f=${1:-hanzo.yml}
|
||||
[ -r "$f" ] || exit 0 # no config, nothing declared, nothing to refuse
|
||||
|
||||
names=$(yq -r '[(.images // [])[] | (.build_secrets // [])[]] | unique | .[]' "$f" 2>/dev/null) || exit 0
|
||||
[ -n "$names" ] || exit 0
|
||||
|
||||
rc=0
|
||||
for n in $names; do
|
||||
case "$n" in
|
||||
# Prefixes a bundler already treats as client-side, plus an explicit
|
||||
# self-declaration for everything else. Anything outside this set has not
|
||||
# claimed to be public, so it is not baked.
|
||||
PUBLISHABLE_*|PUBLIC_*|NEXT_PUBLIC_*|EXPO_PUBLIC_*|NUXT_PUBLIC_*|VITE_*|REACT_APP_*|*_PUBLISHABLE|*_PUBLIC) ;;
|
||||
*)
|
||||
echo "::error::build_secret '$n' does not declare itself publishable, and a build_secret is baked into the image as a --build-arg where \`docker history\` reveals it. Rename it (PUBLISHABLE_*, PUBLIC_*, NEXT_PUBLIC_*, VITE_*, REACT_APP_*) if the value is public on purpose; if it is a real credential it cannot be a build_secret at all." >&2
|
||||
rc=1 ;;
|
||||
esac
|
||||
done
|
||||
exit $rc
|
||||
Executable
+65
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env bash
|
||||
# Tests for bin/publishable. Offline and deterministic: every case is a
|
||||
# hanzo.yml written into a temp dir. Run: bash bin/publishable_test.sh
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
PUB="$PWD/bin/publishable"
|
||||
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
|
||||
fail=0
|
||||
|
||||
# t <name> <want-rc> <build_secrets yaml-inline list>
|
||||
t() {
|
||||
local name=$1 want=$2 list=$3
|
||||
local d="$tmp/$RANDOM$RANDOM"; mkdir -p "$d"
|
||||
{ echo 'images:'; echo ' - name: app'; echo ' repo: ghcr.io/hanzoai/app'
|
||||
[ -n "$list" ] && echo " build_secrets: $list"; } > "$d/hanzo.yml"
|
||||
out=$(bash "$PUB" "$d/hanzo.yml" 2>&1); rc=$?
|
||||
if [ "$rc" = "$want" ]; then printf 'ok %-56s rc=%s\n' "$name" "$rc"
|
||||
else printf 'FAIL %-56s rc=%s (want %s)\n %s\n' "$name" "$rc" "$want" "$out"; fail=1; fi
|
||||
}
|
||||
|
||||
echo "--- refused: a name that never claimed to be public ---"
|
||||
# The live case. hanzoai/ui declares exactly this, and its value IS publishable
|
||||
# — but nothing outside its own Dockerfile could know that.
|
||||
t "EVENT_INGEST_KEY is refused" 1 '[EVENT_INGEST_KEY]'
|
||||
t "a real credential is refused" 1 '[STRIPE_SECRET_KEY]'
|
||||
t "a token is refused" 1 '[GITHUB_TOKEN]'
|
||||
t "a password is refused" 1 '[DB_PASSWORD]'
|
||||
t "a private key is refused" 1 '[SIGNING_PRIVATE_KEY]'
|
||||
t "one bad name among good ones is refused" 1 '[VITE_GTM_ID, EVENT_INGEST_KEY]'
|
||||
|
||||
echo "--- allowed: the name declares it ---"
|
||||
# These are the fleet's real declarations, verbatim.
|
||||
t "PUBLISHABLE_KEY (hanzoai/docs)" 0 '[PUBLISHABLE_KEY]'
|
||||
t "VITE_MAPBOX_TOKEN (hanzoai/world)" 0 '[VITE_MAPBOX_TOKEN]'
|
||||
t "VITE_SENTRY_DSN (hanzoai/world)" 0 '[VITE_SENTRY_DSN]'
|
||||
t "world's four together" 0 '[VITE_MAPBOX_TOKEN, VITE_SENTRY_DSN, VITE_ANALYTICS_WEBSITE_ID, VITE_GTM_ID]'
|
||||
t "NEXT_PUBLIC_ prefix" 0 '[NEXT_PUBLIC_INGEST_KEY]'
|
||||
t "REACT_APP_ prefix" 0 '[REACT_APP_MAP_KEY]'
|
||||
t "EXPO_PUBLIC_ prefix" 0 '[EXPO_PUBLIC_API_KEY]'
|
||||
t "NUXT_PUBLIC_ prefix" 0 '[NUXT_PUBLIC_API_KEY]'
|
||||
t "PUBLIC_ prefix" 0 '[PUBLIC_ANALYTICS_ID]'
|
||||
t "_PUBLISHABLE suffix" 0 '[STRIPE_PUBLISHABLE]'
|
||||
t "_PUBLIC suffix" 0 '[ANALYTICS_ID_PUBLIC]'
|
||||
|
||||
echo "--- silent: nothing declared, nothing to say ---"
|
||||
# 44 of the fleet's 47 repos are this case and must be byte-for-byte unchanged.
|
||||
t "no build_secrets key at all" 0 ''
|
||||
t "empty build_secrets list" 0 '[]'
|
||||
|
||||
echo "--- a missing file is not a refusal ---"
|
||||
out=$(bash "$PUB" "$tmp/does-not-exist.yml" 2>&1); rc=$?
|
||||
if [ "$rc" = 0 ]; then printf 'ok %-56s rc=0\n' "absent hanzo.yml is silent"
|
||||
else printf 'FAIL %-56s rc=%s\n' "absent hanzo.yml is silent" "$rc"; fail=1; fi
|
||||
|
||||
echo "--- the refusal says what to do about it ---"
|
||||
d="$tmp/msg"; mkdir -p "$d"
|
||||
printf 'images:\n - name: app\n build_secrets: [EVENT_INGEST_KEY]\n' > "$d/hanzo.yml"
|
||||
out=$(bash "$PUB" "$d/hanzo.yml" 2>&1)
|
||||
for pat in "EVENT_INGEST_KEY" "docker history" "PUBLISHABLE_" "cannot be a build_secret"; do
|
||||
if printf '%s' "$out" | grep -qF "$pat"; then printf 'ok %-56s\n' "message names '$pat'"
|
||||
else printf 'FAIL %-56s\n got: %s\n' "message names '$pat'" "$out"; fail=1; fi
|
||||
done
|
||||
|
||||
[ "$fail" = 0 ] && echo "PASS" || echo "FAIL"
|
||||
exit $fail
|
||||
Executable
+238
@@ -0,0 +1,238 @@
|
||||
#!/usr/bin/env bash
|
||||
# sitedeploy — publish a built static export to the Hanzo PaaS Sites plane.
|
||||
# One implementation, every static site.
|
||||
#
|
||||
# sitedeploy <slug> <dir> # e.g. sitedeploy hanzo-ai out
|
||||
#
|
||||
# WHY THIS IS A SCRIPT AND NOT INLINE SHELL: the same forty lines of enqueue →
|
||||
# upload → complete were about to be pasted into hanzo.ai, hanzo.app, hips,
|
||||
# computer and every static surface after them. Written N times it is right N-1
|
||||
# times and then wrong once — which is exactly how `sha-<short>` became the only
|
||||
# tag eleven repos ever published (see bin/imgver). The plane's contract lives
|
||||
# here, once.
|
||||
#
|
||||
# THE THREE STEPS, and why the bytes never pass through the API:
|
||||
#
|
||||
# POST /v1/projects/<slug>/deploy -> 202 {id, bucket, prefix, upload}
|
||||
# POST <upload.url> per file -> the bytes, straight to S3
|
||||
# POST /v1/projects/<slug>/deployments/<id>/complete
|
||||
#
|
||||
# A real export is large — hanzo.ai is 128 MB across 8403 files — and cloud's
|
||||
# BodyLimit is 16 MiB, so an artifact POST is refused by fasthttp BEFORE any
|
||||
# handler runs, and it fails as an opaque 400 "Error when parsing request" that
|
||||
# reads like a malformed payload rather than a size cap. The git source is the
|
||||
# documented route for exactly this shape.
|
||||
#
|
||||
# NO STANDING S3 CREDENTIAL. The 202 carries `upload`: a presigned POST policy
|
||||
# that is prefix-scoped (starts-with $key "<org>/<slug>/", enforced by S3 itself),
|
||||
# short-lived (30 min) and size-bounded. That replaced handing every repo the
|
||||
# bucket's own long-lived access key — one key for a bucket whose only tenant
|
||||
# separation is the key prefix, so every repo holding it could overwrite EVERY
|
||||
# org's site (cloud apps/projects/grant.go). Do not reintroduce SITES_S3_* here.
|
||||
# HANZO_DEPLOY_TOKEN is the ONE credential this needs.
|
||||
#
|
||||
# DELETION IS THE SERVER'S. The grant authorizes writes only, so CI cannot remove
|
||||
# a file; `keys` in the completion is the manifest cloud reconciles the prefix
|
||||
# against, and it deletes what the build no longer produces. That is where
|
||||
# `aws s3 sync --delete` went. It fails CLOSED on an empty manifest, and so does
|
||||
# this script — a build that enumerated nothing has failed, and honouring it
|
||||
# literally would delete the live site.
|
||||
#
|
||||
# ENV: HANZO_DEPLOY_TOKEN (required) the org-scoped `sk-` key; mint at POST /v1/keys
|
||||
# HANZO_API (https://api.hanzo.ai)
|
||||
# SITEDEPLOY_JOBS (24) parallel uploads
|
||||
# SITEDEPLOY_COMMIT / SITEDEPLOY_BRANCH recorded on the deployment
|
||||
# SITEDEPLOY_PLAN=1 print the manifest and exit; no network. The test seam.
|
||||
set -euo pipefail
|
||||
|
||||
slug="${1:?usage: sitedeploy <slug> <dir>}"
|
||||
dir="${2:?usage: sitedeploy <slug> <dir>}"
|
||||
api="${HANZO_API:-https://api.hanzo.ai}"
|
||||
jobs="${SITEDEPLOY_JOBS:-24}"
|
||||
|
||||
[ -d "$dir" ] || { echo "::error::$dir is not a directory — the build produced no export"; exit 1; }
|
||||
|
||||
# ---- content type -----------------------------------------------------------
|
||||
# The presigned POST carries no Content-Type condition, so whatever CI sends is
|
||||
# what the object stores — and what it stores is what the edge serves. Send
|
||||
# nothing and every page is application/octet-stream, which a browser DOWNLOADS
|
||||
# instead of rendering: a green deploy that serves an unusable site.
|
||||
ctype() {
|
||||
case "${1##*.}" in
|
||||
html|htm) echo 'text/html; charset=utf-8' ;;
|
||||
css) echo 'text/css; charset=utf-8' ;;
|
||||
js|mjs) echo 'text/javascript; charset=utf-8' ;;
|
||||
json) echo 'application/json; charset=utf-8' ;;
|
||||
xml) echo 'application/xml; charset=utf-8' ;;
|
||||
txt) echo 'text/plain; charset=utf-8' ;;
|
||||
svg) echo 'image/svg+xml' ;;
|
||||
png) echo 'image/png' ;;
|
||||
jpg|jpeg) echo 'image/jpeg' ;;
|
||||
gif) echo 'image/gif' ;;
|
||||
webp) echo 'image/webp' ;;
|
||||
avif) echo 'image/avif' ;;
|
||||
ico) echo 'image/x-icon' ;;
|
||||
woff) echo 'font/woff' ;;
|
||||
woff2) echo 'font/woff2' ;;
|
||||
ttf) echo 'font/ttf' ;;
|
||||
otf) echo 'font/otf' ;;
|
||||
wasm) echo 'application/wasm' ;;
|
||||
pdf) echo 'application/pdf' ;;
|
||||
webmanifest) echo 'application/manifest+json' ;;
|
||||
map) echo 'application/json' ;;
|
||||
*) echo 'application/octet-stream' ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ---- cache control ----------------------------------------------------------
|
||||
# Mirrors cloud's apps/sites.CacheControlFor so a site cached the same whichever
|
||||
# route its bytes took. bin/sitedeploy_test.sh pins these strings; if the server
|
||||
# changes its policy the test is what catches the drift.
|
||||
#
|
||||
# A fingerprinted asset (Vite/Next/webpack emit app.4f3a9c21.js) is immutable: a
|
||||
# new build changes the hash, so the old URL can be cached forever.
|
||||
#
|
||||
# The class is written `[._-]` with the dash LAST and the pattern held in a
|
||||
# variable. Go's regexp spells the same set `[.\-_]`, and transcribing that
|
||||
# literally into `[[ =~ ]]` is a silent defect twice over: a backslash is literal
|
||||
# inside a POSIX bracket expression, so `\-_` reads as the RANGE \…_ and the shell
|
||||
# rejects it as "invalid character range" — and because the `if` merely evaluates
|
||||
# false, every fingerprinted asset quietly fell back to max-age=3600 instead of
|
||||
# immutable. An unquoted inline pattern also has its own quoting hazards; the
|
||||
# variable form is the one that is read as a regex rather than a glob.
|
||||
fingerprint_re='[._-][0-9a-fA-F]{8,}\.[a-z0-9]+$'
|
||||
cachectl() {
|
||||
local k="$1" base="${1##*/}"
|
||||
case "${k##*.}" in
|
||||
html|htm) echo 'public, max-age=60, s-maxage=86400' ;;
|
||||
js|mjs|css|woff|woff2|png|jpg|jpeg|gif|svg|webp|avif|ico|ttf|otf|wasm|data|pck|unityweb|mem)
|
||||
if [[ "$base" =~ $fingerprint_re ]]; then
|
||||
echo 'public, max-age=31536000, immutable'
|
||||
else
|
||||
echo 'public, max-age=3600'
|
||||
fi ;;
|
||||
*) echo 'public, max-age=3600' ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ---- the manifest -----------------------------------------------------------
|
||||
# Paths RELATIVE to the export root, which is exactly what the completion's
|
||||
# `keys` must carry (cloud reconciles `keep[rel]` against them).
|
||||
#
|
||||
# CNAME does not travel. It is a GitHub Pages artifact that means nothing to S3
|
||||
# and would ship a stale hostname claim into the bucket.
|
||||
manifest=$(cd "$dir" && find . -type f ! -name CNAME | sed 's|^\./||' | LC_ALL=C sort)
|
||||
count=$(printf '%s' "$manifest" | grep -c . || true)
|
||||
[ "$count" -gt 0 ] || { echo "::error::$dir contains no files — refusing to deploy an empty manifest"; exit 1; }
|
||||
|
||||
if [ -n "${SITEDEPLOY_PLAN:-}" ]; then
|
||||
printf 'slug=%s dir=%s files=%s\n' "$slug" "$dir" "$count"
|
||||
while IFS= read -r k; do [ -n "$k" ] && printf '%s\t%s\t%s\n' "$k" "$(ctype "$k")" "$(cachectl "$k")"; done <<< "$manifest"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
: "${HANZO_DEPLOY_TOKEN:?HANZO_DEPLOY_TOKEN is unset. It is the ONE credential this needs; set it on the forge (git.hanzo.ai), which is what reads .hanzo/workflows — not on GitHub. Mint: POST /v1/keys {\"type\":\"secret\"}}"
|
||||
|
||||
# ---- 0. ensure the project exists (self-provision) --------------------------
|
||||
# A brand-new slug has no project, and the enqueue below 404s on a missing one —
|
||||
# which is why every never-before-deployed site failed its first run. Create it
|
||||
# idempotently here instead: 200/201 the first time, 409 every time after, both
|
||||
# success. So a new site needs no out-of-band `POST /v1/projects`; committing the
|
||||
# workflow is enough. The repo is linked because the git-source enqueue requires
|
||||
# a linked project (deployGit rejects an unlinked one); SITEDEPLOY_REPO overrides
|
||||
# the origin for a forge checkout whose remote is not the canonical repo.
|
||||
repo="${SITEDEPLOY_REPO:-$(git remote get-url origin 2>/dev/null || true)}"
|
||||
pcode=$(curl -sS -o /tmp/sd-proj.json -w '%{http_code}' \
|
||||
-X POST "$api/v1/projects" \
|
||||
-H "Authorization: Bearer $HANZO_DEPLOY_TOKEN" -H 'Content-Type: application/json' \
|
||||
-d "$(jq -nc --arg s "$slug" --arg u "$repo" --arg b "${SITEDEPLOY_BRANCH:-main}" \
|
||||
'{slug:$s, name:$s} + (if $u=="" then {} else {repo:{url:$u, branch:$b}} end)')")
|
||||
case "$pcode" in
|
||||
200|201|409) : ;; # created now, or already there — either is the state we need
|
||||
*) echo "::error::ensure project $slug returned HTTP $pcode"; head -c 400 /tmp/sd-proj.json; echo; exit 1 ;;
|
||||
esac
|
||||
|
||||
# ---- 1. enqueue -------------------------------------------------------------
|
||||
# 202 Accepted is the success code: the deployment is queued, not live. bucket
|
||||
# and prefix come FROM cloud (sitePrefix(org, slug) is server-side) — never guess
|
||||
# them, or the upload lands where nothing is served the moment an org or slug
|
||||
# changes.
|
||||
code=$(curl -sS -o /tmp/sd-enq.json -w '%{http_code}' \
|
||||
-X POST "$api/v1/projects/$slug/deploy" \
|
||||
-H "Authorization: Bearer $HANZO_DEPLOY_TOKEN" -H 'Content-Type: application/json' \
|
||||
-d "{\"source\":\"git\",\"commit\":\"${SITEDEPLOY_COMMIT:-}\",\"branch\":\"${SITEDEPLOY_BRANCH:-main}\"}")
|
||||
if [ "$code" != "202" ]; then
|
||||
echo "::error::enqueue $api/v1/projects/$slug/deploy returned HTTP $code"; head -c 600 /tmp/sd-enq.json; echo; exit 1
|
||||
fi
|
||||
dep=$(jq -r '.id' /tmp/sd-enq.json)
|
||||
prefix=$(jq -r '.prefix' /tmp/sd-enq.json)
|
||||
upload_url=$(jq -r '.upload.url // empty' /tmp/sd-enq.json)
|
||||
if [ -z "$upload_url" ]; then
|
||||
echo "::error::the 202 carried no upload grant, so there is no way to write the bytes."
|
||||
echo " cloud mints one only when presigning is configured (S3_ADMIN_* on the cloud deployment)."
|
||||
exit 1
|
||||
fi
|
||||
jq -c '{id,version,status,bucket,prefix}' /tmp/sd-enq.json
|
||||
|
||||
# A build that dies after this point would leave the deployment "queued" and the
|
||||
# project stuck "building" forever. Report the failure so cloud records an honest
|
||||
# terminal state instead of a lie by omission.
|
||||
fail() {
|
||||
curl -sS -X POST "$api/v1/projects/$slug/deployments/$dep/complete" \
|
||||
-H "Authorization: Bearer $HANZO_DEPLOY_TOKEN" -H 'Content-Type: application/json' \
|
||||
-d '{"status":"error","message":"CI upload failed"}' >/dev/null 2>&1 || true
|
||||
echo "::error::marked deployment $dep as error"
|
||||
}
|
||||
trap 'fail' ERR
|
||||
|
||||
# ---- 2. the bytes -----------------------------------------------------------
|
||||
# `file` goes LAST: S3 ignores every field after the file part, so a grant field
|
||||
# trailing the body is silently dropped and the signature check fails.
|
||||
#
|
||||
# `key` is DROPPED from the grant's fields and re-sent per object. The grant
|
||||
# carries key="<org>/<slug>/" — the starts-with PLACEHOLDER, not a destination —
|
||||
# and forwarding it verbatim alongside the real key posts `key` twice, which S3
|
||||
# answers 400 for every object. That is the whole of the first end-to-end run:
|
||||
# 8403 files, 8403 400s. Everything else in the map (bucket, policy, x-amz-*) is
|
||||
# covered by the signature and must travel untouched.
|
||||
jq -r '.upload.fields | to_entries[] | select(.key != "key") | "-F\n\(.key)=\(.value)"' /tmp/sd-enq.json > /tmp/sd-fields
|
||||
put() {
|
||||
local rel="$1" args=() line
|
||||
# A read loop, NOT `mapfile`: mapfile is bash 4+, and macOS ships bash 3.2, so
|
||||
# on a dev box it fails as `command not found`, the array stays EMPTY, and every
|
||||
# upload goes out with no policy or signature at all. S3 then rejects it for a
|
||||
# malformed X-Amz-Credential — an error that points at the credential rather
|
||||
# than at the array that never got built.
|
||||
while IFS= read -r line; do args+=("$line"); done < /tmp/sd-fields
|
||||
curl -sS --fail-with-body -o /dev/null \
|
||||
-X POST "$UP_URL" \
|
||||
-F "key=$PREFIX/$rel" "${args[@]}" \
|
||||
-F "Content-Type=$(ctype "$rel")" -F "Cache-Control=$(cachectl "$rel")" \
|
||||
-F "file=@$DIR/$rel" \
|
||||
|| { echo "::error::upload failed: $rel"; return 1; }
|
||||
}
|
||||
export -f put ctype cachectl
|
||||
export UP_URL="$upload_url" PREFIX="$prefix" DIR="$dir" fingerprint_re
|
||||
|
||||
echo "uploading $count files to s3://$(jq -r .bucket /tmp/sd-enq.json)/$prefix ($jobs parallel)"
|
||||
printf '%s\n' "$manifest" | grep . | xargs -P "$jobs" -I{} bash -c 'put "$@"' _ {}
|
||||
|
||||
# ---- 3. flip it live --------------------------------------------------------
|
||||
# `wc -c`, not `stat`: the size flag is spelled -f%z on BSD and -c%s on GNU, and a
|
||||
# `stat -f%z || stat -c%s` fallback does not work — on Linux the first arm fails
|
||||
# INSIDE the pipeline, awk still exits 0, and the `||` never fires, so the byte
|
||||
# count silently reports 0. wc is the one spelling both agree on.
|
||||
bytes=$(cd "$dir" && find . -type f ! -name CNAME -exec wc -c {} + | awk '$2!="total"{s+=$1} END{print s+0}')
|
||||
jq -n --arg c "${SITEDEPLOY_COMMIT:-}" --argjson f "$count" --argjson b "${bytes:-0}" \
|
||||
--args '{status:"live",commit:$c,files:$f,bytes:$b,keys:$ARGS.positional}' \
|
||||
$(printf '%s\n' "$manifest" | grep .) > /tmp/sd-done.json
|
||||
|
||||
code=$(curl -sS -o /tmp/sd-resp.json -w '%{http_code}' \
|
||||
-X POST "$api/v1/projects/$slug/deployments/$dep/complete" \
|
||||
-H "Authorization: Bearer $HANZO_DEPLOY_TOKEN" -H 'Content-Type: application/json' \
|
||||
--data-binary @/tmp/sd-done.json)
|
||||
trap - ERR
|
||||
if [ "$code" != "200" ]; then
|
||||
echo "::error::complete returned HTTP $code"; head -c 600 /tmp/sd-resp.json; echo; fail; exit 1
|
||||
fi
|
||||
jq -c '{status,liveUrl,version,files,bytes}' /tmp/sd-resp.json
|
||||
Executable
+80
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env bash
|
||||
# Tests for bin/sitedeploy. Runs OFFLINE: SITEDEPLOY_PLAN=1 stops the script
|
||||
# before the first network call and prints the manifest it would upload, so every
|
||||
# case here is deterministic and needs no token, no bucket and no cluster.
|
||||
# Run: bash bin/sitedeploy_test.sh
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
SD="$PWD/bin/sitedeploy"
|
||||
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
|
||||
fail=0
|
||||
|
||||
plan() { SITEDEPLOY_PLAN=1 bash "$SD" a-slug "$1" 2>&1; }
|
||||
t() { # t <name> <got> <want>
|
||||
if [ "$2" = "$3" ]; then printf 'ok %-56s -> %s\n' "$1" "$2"
|
||||
else printf 'FAIL %-56s -> %s (want %s)\n' "$1" "$2" "$3"; fail=1; fi
|
||||
}
|
||||
# field <dir> <key> <col> — the ctype (2) or cachectl (3) column for one key
|
||||
field() { plan "$1" | awk -F'\t' -v k="$2" -v c="$3" '$1==k{print $c}'; }
|
||||
|
||||
site="$tmp/site"; mkdir -p "$site/assets" "$site/nested/deep"
|
||||
echo '<h1>hi</h1>' > "$site/index.html"
|
||||
echo 'body{}' > "$site/assets/app.4f3a9c21.css"
|
||||
echo 'x' > "$site/assets/plain.css"
|
||||
echo 'y' > "$site/assets/chunk-AB12CD34.js"
|
||||
echo '{}' > "$site/data.json"
|
||||
echo 'z' > "$site/nested/deep/page.html"
|
||||
echo 'hanzo.ai' > "$site/CNAME"
|
||||
|
||||
# --- the manifest ------------------------------------------------------------
|
||||
# Keys are RELATIVE to the export root: cloud reconciles keep[rel] against them,
|
||||
# so a leading ./ or an absolute path would match nothing and the completion
|
||||
# would prune the entire live site.
|
||||
t "keys are relative, no leading ./" "$(plan "$site" | awk -F'\t' 'NR>1&&$1~/^\.?\//{print "ABS"}' | head -1)" ""
|
||||
t "nested paths keep their subdirs" "$(plan "$site" | awk -F'\t' '$1=="nested/deep/page.html"{print "yes"}')" "yes"
|
||||
# CNAME is a GitHub Pages artifact: it means nothing to S3 and would ship a stale
|
||||
# hostname claim into the bucket.
|
||||
t "CNAME does not travel" "$(plan "$site" | awk -F'\t' '$1=="CNAME"{print "leaked"}')" ""
|
||||
t "file count excludes CNAME" "$(plan "$site" | head -1 | grep -o 'files=[0-9]*')" "files=6"
|
||||
|
||||
# --- content type ------------------------------------------------------------
|
||||
# The presigned POST carries no Content-Type condition, so what CI sends is what
|
||||
# the object stores and what the edge serves. Send nothing and a browser
|
||||
# DOWNLOADS every page instead of rendering it.
|
||||
t "html" "$(field "$site" index.html 2)" "text/html; charset=utf-8"
|
||||
t "css" "$(field "$site" assets/plain.css 2)" "text/css; charset=utf-8"
|
||||
t "js" "$(field "$site" assets/chunk-AB12CD34.js 2)" "text/javascript; charset=utf-8"
|
||||
t "json" "$(field "$site" data.json 2)" "application/json; charset=utf-8"
|
||||
|
||||
# --- cache control: mirrors cloud apps/sites.CacheControlFor -----------------
|
||||
# These strings are the SERVER's policy, pinned here so the two cannot drift
|
||||
# apart silently. If cloud changes CacheControlFor, this is what goes red.
|
||||
t "html is short-lived, long at the edge" "$(field "$site" index.html 3)" "public, max-age=60, s-maxage=86400"
|
||||
t "unfingerprinted asset is an hour" "$(field "$site" assets/plain.css 3)" "public, max-age=3600"
|
||||
# The regression this pins: Go spells the class [.\-_], and transcribing that
|
||||
# into [[ =~ ]] makes the shell reject it as an invalid character range. The `if`
|
||||
# then merely evaluates false, so every hashed asset silently lost `immutable`.
|
||||
t "fingerprinted .hash. is immutable" "$(field "$site" assets/app.4f3a9c21.css 3)" "public, max-age=31536000, immutable"
|
||||
t "fingerprinted -HASH- is immutable" "$(field "$site" assets/chunk-AB12CD34.js 3)" "public, max-age=31536000, immutable"
|
||||
|
||||
# --- fail closed -------------------------------------------------------------
|
||||
# reconcilePrefix deletes whatever the manifest omits, so an empty manifest is a
|
||||
# request to delete the live site. A build that enumerated nothing has failed.
|
||||
empty="$tmp/empty"; mkdir -p "$empty"
|
||||
plan "$empty" >/dev/null 2>&1
|
||||
t "empty export is refused" "$?" "1"
|
||||
only_cname="$tmp/onlycname"; mkdir -p "$only_cname"; echo x > "$only_cname/CNAME"
|
||||
plan "$only_cname" >/dev/null 2>&1
|
||||
t "a dir holding only CNAME is empty too" "$?" "1"
|
||||
plan "$tmp/does-not-exist" >/dev/null 2>&1
|
||||
t "missing export dir is refused" "$?" "1"
|
||||
|
||||
# --- the credential ----------------------------------------------------------
|
||||
# Not in PLAN mode (that is the offline seam), but a real run must refuse to
|
||||
# start rather than enqueue a deployment it cannot complete.
|
||||
out=$(HANZO_DEPLOY_TOKEN= bash "$SD" a-slug "$site" 2>&1); rc=$?
|
||||
t "no token: exits non-zero" "$rc" "1"
|
||||
t "no token: says which secret" "$(printf '%s' "$out" | grep -c HANZO_DEPLOY_TOKEN)" "1"
|
||||
|
||||
[ $fail -eq 0 ] && echo "PASS" || echo "FAIL"
|
||||
exit $fail
|
||||
Executable
+220
@@ -0,0 +1,220 @@
|
||||
#!/usr/bin/env bash
|
||||
# sitepublish — publish a built static export as an immutable Release on the
|
||||
# Hanzo Sites plane. One implementation, every `site:` in the fleet.
|
||||
#
|
||||
# sitepublish <slug> <dir> # e.g. sitepublish hanzo-console out
|
||||
#
|
||||
# WHY THIS EXISTS: the reusable's `site:` lane used to stage the export with
|
||||
# `mc mirror` against hanzoai/s3, which needed S3_ADMIN_ACCESS_KEY and
|
||||
# S3_ADMIN_SECRET_KEY. Those names are not in KMS for any org, and the lane
|
||||
# fails closed without them — so `site:` refused for every caller that ever
|
||||
# declared it, and hanzoai/console had to grow its own build→zip→publish
|
||||
# workflow to ship at all. Per-repo build logic is the one thing this repo
|
||||
# exists to prevent. The bytes now travel the SAME route console proved, and
|
||||
# the credential is the one CI already holds.
|
||||
#
|
||||
# THE TWO STEPS:
|
||||
#
|
||||
# POST /v1/projects/<slug>/deploy Content-Type: application/zip, zip as body
|
||||
# POST /v1/sites/<slug>/publish {"source":"<slug>"}
|
||||
#
|
||||
# The first lands the export at the org's site prefix; the second promotes that
|
||||
# prefix into an immutable Release and flips it live. `source` is ORG-RELATIVE
|
||||
# and the org segment is prepended server-side from the validated principal
|
||||
# (cloud apps/projects/blob.go:60, `sitePrefix(org, slug) = org + "/" + slug`),
|
||||
# which is why "<slug>" is the whole of it and why a caller cannot address
|
||||
# another tenant's bytes by writing a longer path.
|
||||
#
|
||||
# ONE CREDENTIAL, ALREADY MINTED. HANZO_API_TOKEN is the IAM JWT the workflow's
|
||||
# KMS step already holds: cloud's /v1/kms/auth/login is a broker that performs
|
||||
# the IAM client_credentials exchange and returns IAM's own JWT verbatim, so the
|
||||
# token that reads this org's secrets is the token that publishes this org's
|
||||
# site. api.hanzo.ai validates it and mints X-Org-Id from the `owner` claim —
|
||||
# identity is never a header this script sends, so there is nothing here to
|
||||
# spoof. Do NOT reintroduce S3_ADMIN_*; a standing bucket key was what this
|
||||
# replaced.
|
||||
#
|
||||
# THE SIZE BOUNDARY IS THE SERVER'S, NOT A PREFERENCE. cloud's public edge sets
|
||||
# BodyLimit from GATEWAY_BODY_LIMIT, default 16 MiB (cloud config.go:312), and
|
||||
# fasthttp refuses an oversized POST BEFORE any handler runs — it surfaces as an
|
||||
# opaque 400 "Error when parsing request" that reads like a malformed payload
|
||||
# rather than a size cap (cloud apps/projects/grant.go:5 tells that story about
|
||||
# a ~170 MB export). So the zip is measured HERE and refused HERE, with the
|
||||
# number and the alternative, instead of being sent to fail unreadably. Measured
|
||||
# across the estate's 24 built exports, 22 fit; hanzo.ai (27.9 MiB zipped, 8536
|
||||
# files) and trillerfest.com (76.7 MiB) do not. Those go to bin/sitedeploy,
|
||||
# which streams per-file against a presigned grant and has no body limit —
|
||||
# hanzo.ai is over the server's own 5000-entry cap anyway, so no transport makes
|
||||
# it a Release.
|
||||
#
|
||||
# ENV: HANZO_API_TOKEN (required) the IAM bearer; the workflow's KMS step mints it
|
||||
# HANZO_API (https://api.hanzo.ai)
|
||||
# SITEPUBLISH_MAX_ZIP (16777216) mirror of the server's GATEWAY_BODY_LIMIT
|
||||
# SITEPUBLISH_PLAN=1 print what it would send and exit; no network. Test seam.
|
||||
set -euo pipefail
|
||||
|
||||
slug="${1:?usage: sitepublish <slug> <dir>}"
|
||||
dir="${2:?usage: sitepublish <slug> <dir>}"
|
||||
api="${HANZO_API:-https://api.hanzo.ai}"
|
||||
|
||||
# Server-side caps, mirrored so a breach is named in CI instead of arriving as a
|
||||
# 413 (or, for the body limit, as an unreadable 400). cloud apps/projects/blob.go
|
||||
# :29-31. bin/sitepublish_test.sh pins these numbers; if cloud moves them the
|
||||
# test is what catches the drift.
|
||||
max_zip="${SITEPUBLISH_MAX_ZIP:-16777216}" # gateway BodyLimit, 16 MiB
|
||||
max_files=5000 # maxFiles
|
||||
max_file_bytes=$((64 << 20)) # maxFileBytes, 64 MiB
|
||||
max_total_bytes=$((512 << 20)) # maxTotalBytes uncompressed, 512 MiB
|
||||
|
||||
die() { echo "::error::$*"; exit 1; }
|
||||
|
||||
# The slug is a URL path segment AND an S3 key segment, so it is held to the
|
||||
# grammar cloud validates project slugs with — checked before anything is built
|
||||
# or sent, because a bad slug is a typo to fix and not a 404 to interpret.
|
||||
echo "$slug" | grep -qE '^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?$' \
|
||||
|| die "slug '$slug' is not a project slug (^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?\$)"
|
||||
[ -d "$dir" ] || die "'$dir' is not a directory — the build produced no export"
|
||||
|
||||
# A site is a thing with an index. cloud enforces it however the bytes arrive;
|
||||
# checking here turns a failed build into a failed publish with a readable cause
|
||||
# rather than a promoted release that 404s at its own root.
|
||||
[ -f "$dir/index.html" ] || die "'$dir' has no index.html at its root — /v1/sites refuses a source without one"
|
||||
|
||||
# CNAME does not travel: a GitHub Pages artifact that means nothing to S3 and
|
||||
# would ship a stale hostname claim into the bucket. Same exclusion bin/sitedeploy
|
||||
# makes, for the same reason.
|
||||
files=$(cd "$dir" && find . -type f ! -name CNAME | wc -l)
|
||||
[ "$files" -gt 0 ] || die "'$dir' contains no files — refusing to publish an empty release"
|
||||
[ "$files" -le "$max_files" ] \
|
||||
|| die "'$dir' holds $files files; cloud caps one artifact at $max_files (apps/projects/blob.go maxFiles). Use bin/sitedeploy — but note the Releases plane has the same cap, so this export cannot become a Release."
|
||||
|
||||
bytes=$(cd "$dir" && find . -type f ! -name CNAME -exec wc -c {} + | awk '$2!="total"{s+=$1} END{print s+0}')
|
||||
[ "$bytes" -le "$max_total_bytes" ] \
|
||||
|| die "'$dir' is $bytes bytes uncompressed; cloud caps an artifact at $max_total_bytes (maxTotalBytes)"
|
||||
|
||||
# Per-file cap, checked with find rather than a loop so a 9000-file export costs
|
||||
# one traversal. -size uses 512-byte blocks with `c` for bytes; +N c is "strictly
|
||||
# greater than N bytes", which is the cap's own boundary.
|
||||
big=$(cd "$dir" && find . -type f ! -name CNAME -size +${max_file_bytes}c -printf '%P (%s bytes)\n' | head -3)
|
||||
[ -z "$big" ] || die "these files exceed cloud's ${max_file_bytes}-byte per-file cap (maxFileBytes):
|
||||
$big"
|
||||
|
||||
if [ -n "${SITEPUBLISH_PLAN:-}" ]; then
|
||||
printf 'slug=%s dir=%s files=%s bytes=%s api=%s source=%s\n' \
|
||||
"$slug" "$dir" "$files" "$bytes" "$api" "$slug"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# No apostrophe in this message: inside "${VAR:?word}" a single quote opens a
|
||||
# quoted section for the PARSER, and the script dies at EOF with "unexpected EOF
|
||||
# while looking for matching quote" — a syntax error reported at the last line,
|
||||
# nowhere near the one that caused it.
|
||||
: "${HANZO_API_TOKEN:?HANZO_API_TOKEN is unset. It is the IAM bearer minted by the KMS step of the reusable workflow, from KMS_CLIENT_ID/KMS_CLIENT_SECRET; there is no second credential to seal.}"
|
||||
|
||||
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
|
||||
zipf="$tmp/site.zip"
|
||||
|
||||
# -X drops extra file attributes (uid/gid/timestamps beyond the DOS fields) so
|
||||
# the same tree zips to the same bytes on any runner. Entries are stored
|
||||
# RELATIVE to the export root — cloud strips a leading "./" and refuses absolute
|
||||
# or ".."-escaping names (blob.go:362-370), and strips a common top-level prefix
|
||||
# if one exists, so a flat archive is what it expects and what this sends.
|
||||
( cd "$dir" && zip -qXr "$zipf" . -x './CNAME' ) || die "zip of '$dir' failed"
|
||||
zbytes=$(wc -c < "$zipf")
|
||||
|
||||
# The refusal the whole size story is about. Above this the POST dies at the
|
||||
# edge with a 400 that names nothing.
|
||||
[ "$zbytes" -le "$max_zip" ] || die "the zipped export is $zbytes bytes, past cloud's ${max_zip}-byte edge BodyLimit (GATEWAY_BODY_LIMIT, cloud config.go). fasthttp refuses the POST before any handler runs and reports only 'Error when parsing request', so this is refused here where the number is visible. Publish this export with bin/sitedeploy, which streams per-file against a presigned grant and has no body limit."
|
||||
|
||||
echo "publishing $slug — $files files, $bytes bytes ($zbytes zipped)"
|
||||
|
||||
# ---- 1. the bytes -----------------------------------------------------------
|
||||
# Every response body is KEPT and printed on failure. cloud answers these routes
|
||||
# with a specific status per cause — 402 hosting gate, 403 wrong org, 404 no such
|
||||
# site for this tenant, 409 the source moved mid-publish, 413 past the caps, 503
|
||||
# storage unconfigured — and each is a different fix, so discarding the body
|
||||
# discards the answer.
|
||||
code=$(curl -sS -o "$tmp/deploy.json" -w '%{http_code}' \
|
||||
-X POST "$api/v1/projects/$slug/deploy" \
|
||||
-H "Authorization: Bearer $HANZO_API_TOKEN" \
|
||||
-H 'Content-Type: application/zip' \
|
||||
--data-binary "@$zipf") \
|
||||
|| die "POST $api/v1/projects/$slug/deploy did not complete"
|
||||
case "$code" in
|
||||
2??) ;;
|
||||
*) echo "::error::upload $slug → HTTP $code"; head -c 800 "$tmp/deploy.json"; echo; exit 1 ;;
|
||||
esac
|
||||
|
||||
# ---- 2. promote + activate --------------------------------------------------
|
||||
# `source` is the org-relative prefix the upload just landed at. Content types
|
||||
# and cache policy are not ours to set: copyRelease rewrites the metadata on
|
||||
# every object it promotes, so a release serves identically however its bytes
|
||||
# were staged.
|
||||
code=$(curl -sS -o "$tmp/publish.json" -w '%{http_code}' \
|
||||
-X POST "$api/v1/sites/$slug/publish" \
|
||||
-H "Authorization: Bearer $HANZO_API_TOKEN" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$(jq -nc --arg s "$slug" '{source:$s}')") \
|
||||
|| die "POST $api/v1/sites/$slug/publish did not complete"
|
||||
case "$code" in
|
||||
2??) ;;
|
||||
*) echo "::error::publish $slug → HTTP $code"; head -c 800 "$tmp/publish.json"; echo; exit 1 ;;
|
||||
esac
|
||||
|
||||
# Guard the SHAPE before reading a field out of it. `jq -r '.releaseId // empty'`
|
||||
# on an error body, an array, or a null yields "" and every later test on it
|
||||
# passes vacuously — which is the defect class this whole script is written
|
||||
# against. Assert it is an object carrying the field, then read it.
|
||||
jq -e 'type == "object" and (.releaseId | type) == "string" and (.releaseId | length) > 0' \
|
||||
"$tmp/publish.json" >/dev/null \
|
||||
|| { echo "::error::publish answered $code but the body is not a release object with a releaseId:"; head -c 800 "$tmp/publish.json"; echo; exit 1; }
|
||||
rid=$(jq -r '.releaseId' "$tmp/publish.json")
|
||||
objs=$(jq -r '.objects // 0' "$tmp/publish.json")
|
||||
url=$(jq -r '.url // ""' "$tmp/publish.json")
|
||||
|
||||
# ---- 3. verify the release we just made is the one that is live -------------
|
||||
# A 200 from publish says the request was accepted, not that this release is
|
||||
# serving. The list is the only thing that can say so.
|
||||
#
|
||||
# THE LIST IS A BARE JSON ARRAY — `type projectsReleases []projectsRelease`
|
||||
# (cloud apps/projects/release.go:518). `.releases[]` against it resolves
|
||||
# NOTHING, and an assertion that resolves nothing is an assertion that cannot
|
||||
# fail for the reason it was written. Hence `.[]`, and hence the shape guard
|
||||
# first: if cloud ever wraps this in an object, THAT is what goes red, loudly,
|
||||
# instead of the check quietly matching zero releases forever.
|
||||
code=$(curl -sS -o "$tmp/releases.json" -w '%{http_code}' \
|
||||
"$api/v1/sites/$slug/releases" \
|
||||
-H "Authorization: Bearer $HANZO_API_TOKEN") \
|
||||
|| die "GET $api/v1/sites/$slug/releases did not complete"
|
||||
case "$code" in
|
||||
2??) ;;
|
||||
*) echo "::error::list releases for $slug → HTTP $code"; head -c 800 "$tmp/releases.json"; echo; exit 1 ;;
|
||||
esac
|
||||
jq -e 'type == "array" and length > 0' "$tmp/releases.json" >/dev/null \
|
||||
|| { echo "::error::GET /v1/sites/$slug/releases did not answer a non-empty JSON array (it is the bare-array shape from release.go:518). Body:"; head -c 800 "$tmp/releases.json"; echo; exit 1; }
|
||||
|
||||
# Exactly one release is active. Counting first is what makes "none active"
|
||||
# distinguishable from "the wrong one is active" — a bare grep for '"active":true'
|
||||
# matches ANY release in the list and would pass on both.
|
||||
nactive=$(jq '[.[] | select(.active == true)] | length' "$tmp/releases.json")
|
||||
[ "$nactive" = 1 ] \
|
||||
|| { echo "::error::expected exactly 1 active release for $slug, found $nactive:"; jq -c '[.[]|{releaseId,active}]' "$tmp/releases.json"; exit 1; }
|
||||
|
||||
active=$(jq -r 'map(select(.active == true))[0].releaseId // ""' "$tmp/releases.json")
|
||||
# Both sides proven non-empty BEFORE they are compared. `[ "$a" = "$b" ]` with
|
||||
# two empty strings is TRUE, so an equality test on unguarded values reports
|
||||
# success precisely when it learned nothing.
|
||||
[ -n "$active" ] || { echo "::error::the active release for $slug carries no releaseId:"; jq -c '.' "$tmp/releases.json"; exit 1; }
|
||||
[ -n "$rid" ] || die "publish returned no releaseId to verify against"
|
||||
[ "$active" = "$rid" ] \
|
||||
|| { echo "::error::published $rid but $active is live for $slug — the flip did not take"; exit 1; }
|
||||
|
||||
echo "live: $rid ($objs objects) → ${url:-no public host — that slug is claimed; rename the site or bind a domain}"
|
||||
|
||||
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
|
||||
{ echo "### Site — \`$slug\`"; echo
|
||||
echo "| release | objects | files | zipped | url |"
|
||||
echo "|---|---|---|---|---|"
|
||||
echo "| \`$rid\` | $objs | $files | $zbytes B | ${url:-—} |"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
Executable
+168
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env bash
|
||||
# Tests for bin/sitepublish. Runs OFFLINE and deterministically: a `curl` shim on
|
||||
# PATH answers the three routes from fixtures, so every case here — including the
|
||||
# ones that must FAIL — needs no token, no bucket and no cluster.
|
||||
# Run: bash bin/sitepublish_test.sh
|
||||
#
|
||||
# The suite is weighted toward REFUSALS on purpose. A publish step that reports
|
||||
# success when it verified nothing is worse than one that does not verify at all,
|
||||
# because it is indistinguishable from a working one until a site silently stops
|
||||
# updating. Three real defects of that exact shape are pinned below by name:
|
||||
#
|
||||
# • an object-shaped read (`.releaseId`) against a body that is not an object
|
||||
# • `.releases[]` against the BARE ARRAY /v1/sites/<slug>/releases returns,
|
||||
# which resolves nothing and therefore can never fail for its stated reason
|
||||
# • `[ "$a" = "$b" ]` on two values that are both empty, which is TRUE
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
SP="$PWD/bin/sitepublish"
|
||||
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
|
||||
fail=0
|
||||
|
||||
t() { # t <name> <want_rc> <got_rc> [<must-contain> <output>]
|
||||
local name="$1" want="$2" got="$3" needle="${4:-}" out="${5:-}"
|
||||
if [ "$got" != "$want" ]; then
|
||||
printf 'FAIL %-58s rc=%s (want %s)\n' "$name" "$got" "$want"; fail=1; return
|
||||
fi
|
||||
if [ -n "$needle" ] && ! printf '%s' "$out" | grep -qF -- "$needle"; then
|
||||
printf 'FAIL %-58s rc=%s but missing %q\n' "$name" "$got" "$needle"; fail=1
|
||||
printf ' got: %s\n' "$(printf '%s' "$out" | head -c 300)"; return
|
||||
fi
|
||||
printf 'ok %-58s rc=%s\n' "$name" "$got"
|
||||
}
|
||||
|
||||
# ---- a curl shim -------------------------------------------------------------
|
||||
# Dispatches on the URL and writes the fixture the scenario names to the file the
|
||||
# real curl would have written, then prints the status the way `-w %{http_code}`
|
||||
# does. Everything the script does with a response goes through this, so the
|
||||
# tests exercise the REAL parsing, not a mock of it.
|
||||
shim="$tmp/bin"; mkdir -p "$shim"
|
||||
cat > "$shim/curl" <<'SHIM'
|
||||
#!/usr/bin/env bash
|
||||
out=/dev/null url=
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-o) out="$2"; shift 2 ;;
|
||||
http*|https*) url="$1"; shift ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
case "$url" in
|
||||
*/deploy) printf '%s' "${T_DEPLOY_BODY:-{\"ok\":true\}}" > "$out"; printf '%s' "${T_DEPLOY_CODE:-200}" ;;
|
||||
*/publish) printf '%s' "${T_PUBLISH_BODY:-}" > "$out"; printf '%s' "${T_PUBLISH_CODE:-200}" ;;
|
||||
*/releases) printf '%s' "${T_LIST_BODY:-}" > "$out"; printf '%s' "${T_LIST_CODE:-200}" ;;
|
||||
*) echo "shim: unexpected url $url" >&2; exit 9 ;;
|
||||
esac
|
||||
SHIM
|
||||
chmod +x "$shim/curl"
|
||||
|
||||
site="$tmp/site"; mkdir -p "$site/assets"
|
||||
echo '<h1>hi</h1>' > "$site/index.html"
|
||||
echo 'body{}' > "$site/assets/app.css"
|
||||
echo 'hanzo.ai' > "$site/CNAME"
|
||||
|
||||
run() { # run <dir> — publish with the shim on PATH, current T_* scenario
|
||||
PATH="$shim:$PATH" HANZO_API_TOKEN=tok HANZO_API=https://api.test \
|
||||
bash "$SP" a-slug "$1" 2>&1
|
||||
}
|
||||
OK_PUB='{"releaseId":"rel-1","slug":"a-slug","objects":2,"active":true,"url":"https://a-slug.hanzo.page"}'
|
||||
OK_LIST='[{"releaseId":"rel-1","active":true},{"releaseId":"rel-0","active":false}]'
|
||||
|
||||
# ---- the plan seam: no network, no token ------------------------------------
|
||||
out=$(SITEPUBLISH_PLAN=1 bash "$SP" a-slug "$site" 2>&1); rc=$?
|
||||
t "plan prints the manifest and exits" 0 $rc "files=2" "$out"
|
||||
# CNAME is a GitHub Pages artifact: it means nothing to S3 and would ship a stale
|
||||
# hostname claim. Two files, not three.
|
||||
t "plan excludes CNAME from the count" 0 $rc "files=2 " "$out"
|
||||
t "plan sends source=<slug>, org-relative" 0 $rc "source=a-slug" "$out"
|
||||
|
||||
# ---- refusals that need no network ------------------------------------------
|
||||
out=$(SITEPUBLISH_PLAN=1 bash "$SP" 'Bad_Slug' "$site" 2>&1); rc=$?
|
||||
t "an invalid slug is refused before anything" 1 $rc "is not a project slug" "$out"
|
||||
out=$(SITEPUBLISH_PLAN=1 bash "$SP" a-slug "$tmp/nope" 2>&1); rc=$?
|
||||
t "a missing export dir is refused" 1 $rc "is not a directory" "$out"
|
||||
noidx="$tmp/noidx"; mkdir -p "$noidx"; echo x > "$noidx/page.html"
|
||||
out=$(SITEPUBLISH_PLAN=1 bash "$SP" a-slug "$noidx" 2>&1); rc=$?
|
||||
t "an export with no index.html is refused" 1 $rc "no index.html at its root" "$out"
|
||||
empty="$tmp/empty"; mkdir -p "$empty"; echo x > "$empty/index.html"; rm "$empty/index.html"
|
||||
out=$(SITEPUBLISH_PLAN=1 bash "$SP" a-slug "$empty" 2>&1); rc=$?
|
||||
t "an empty export is refused" 1 $rc "no index.html" "$out"
|
||||
|
||||
# The size refusal is the whole reason this script measures before it sends:
|
||||
# past the edge BodyLimit fasthttp rejects the POST before any handler runs and
|
||||
# reports only "Error when parsing request", which names neither size nor cause.
|
||||
out=$(SITEPUBLISH_MAX_ZIP=1 PATH="$shim:$PATH" HANZO_API_TOKEN=tok \
|
||||
bash "$SP" a-slug "$site" 2>&1); rc=$?
|
||||
t "a zip past the edge BodyLimit is refused here" 1 $rc "BodyLimit" "$out"
|
||||
t " ...and the refusal names bin/sitedeploy" 1 $rc "bin/sitedeploy" "$out"
|
||||
|
||||
# The server caps one artifact at 5000 entries; a 5001-file export can never
|
||||
# become a Release by ANY transport, so saying so here beats a 413 later.
|
||||
many="$tmp/many"; mkdir -p "$many"; echo x > "$many/index.html"
|
||||
( cd "$many" && touch f{1..5001} )
|
||||
out=$(SITEPUBLISH_PLAN=1 bash "$SP" a-slug "$many" 2>&1); rc=$?
|
||||
t "an export past maxFiles=5000 is refused" 1 $rc "cloud caps one artifact at 5000" "$out"
|
||||
|
||||
# ---- the happy path ----------------------------------------------------------
|
||||
out=$(T_PUBLISH_BODY="$OK_PUB" T_LIST_BODY="$OK_LIST" run "$site"); rc=$?
|
||||
t "publish + verified flip succeeds" 0 $rc "live: rel-1" "$out"
|
||||
|
||||
# ---- transport failures carry the server's answer ---------------------------
|
||||
# Each status is a different fix, so the body is printed rather than swallowed.
|
||||
out=$(T_DEPLOY_CODE=413 T_DEPLOY_BODY='{"error":"artifact exceeds"}' run "$site"); rc=$?
|
||||
t "a non-2xx upload fails and prints the body" 1 $rc "artifact exceeds" "$out"
|
||||
out=$(T_PUBLISH_CODE=402 T_PUBLISH_BODY='{"error":"hosting not enabled"}' run "$site"); rc=$?
|
||||
t "a non-2xx publish fails and prints the body" 1 $rc "hosting not enabled" "$out"
|
||||
out=$(T_PUBLISH_BODY="$OK_PUB" T_LIST_CODE=503 T_LIST_BODY='{"error":"storage"}' run "$site"); rc=$?
|
||||
t "a non-2xx release list fails" 1 $rc "list releases" "$out"
|
||||
|
||||
# ---- defect 1: an object-shaped read against a non-object -------------------
|
||||
# `jq -r '.releaseId // empty'` yields "" for an array, a null, or an error body,
|
||||
# and every later test on "" passes vacuously. The shape is asserted first.
|
||||
out=$(T_PUBLISH_BODY='[{"releaseId":"rel-1"}]' T_LIST_BODY="$OK_LIST" run "$site"); rc=$?
|
||||
t "publish answering an ARRAY is refused" 1 $rc "not a release object" "$out"
|
||||
out=$(T_PUBLISH_BODY='{"ok":true}' T_LIST_BODY="$OK_LIST" run "$site"); rc=$?
|
||||
t "publish answering no releaseId is refused" 1 $rc "not a release object" "$out"
|
||||
out=$(T_PUBLISH_BODY='{"releaseId":""}' T_LIST_BODY="$OK_LIST" run "$site"); rc=$?
|
||||
t "publish answering an EMPTY releaseId is refused" 1 $rc "not a release object" "$out"
|
||||
out=$(T_PUBLISH_BODY='not json' T_LIST_BODY="$OK_LIST" run "$site"); rc=$?
|
||||
t "publish answering non-JSON is refused" 1 $rc "not a release object" "$out"
|
||||
|
||||
# ---- defect 2: the list is a BARE ARRAY -------------------------------------
|
||||
# release.go:518 is `type projectsReleases []projectsRelease`. A `.releases[]`
|
||||
# filter resolves NOTHING against that, so an assertion built on it can only ever
|
||||
# refuse — it never once tested what it claimed to. The guard demands the array
|
||||
# shape, so the day cloud wraps the list THAT is what goes red, by name.
|
||||
out=$(T_PUBLISH_BODY="$OK_PUB" T_LIST_BODY='{"releases":[{"releaseId":"rel-1","active":true}]}' run "$site"); rc=$?
|
||||
t "a WRAPPED release list is refused, loudly" 1 $rc "bare-array shape" "$out"
|
||||
out=$(T_PUBLISH_BODY="$OK_PUB" T_LIST_BODY='[]' run "$site"); rc=$?
|
||||
t "an EMPTY release list is refused" 1 $rc "non-empty JSON array" "$out"
|
||||
out=$(T_PUBLISH_BODY="$OK_PUB" T_LIST_BODY='null' run "$site"); rc=$?
|
||||
t "a null release list is refused" 1 $rc "non-empty JSON array" "$out"
|
||||
|
||||
# ---- defect 3: counting, so "none" cannot read as "yes" ---------------------
|
||||
# A bare `grep '"active":true'` matches ANY release in the list, so it passes
|
||||
# both when the wrong release is live and when the list merely mentions one.
|
||||
out=$(T_PUBLISH_BODY="$OK_PUB" T_LIST_BODY='[{"releaseId":"rel-1","active":false}]' run "$site"); rc=$?
|
||||
t "ZERO active releases is refused" 1 $rc "expected exactly 1 active release" "$out"
|
||||
out=$(T_PUBLISH_BODY="$OK_PUB" \
|
||||
T_LIST_BODY='[{"releaseId":"rel-1","active":true},{"releaseId":"rel-2","active":true}]' run "$site"); rc=$?
|
||||
t "TWO active releases is refused" 1 $rc "expected exactly 1 active release" "$out"
|
||||
out=$(T_PUBLISH_BODY="$OK_PUB" T_LIST_BODY='[{"releaseId":"rel-9","active":true}]' run "$site"); rc=$?
|
||||
t "a DIFFERENT release being live is refused" 1 $rc "the flip did not take" "$out"
|
||||
|
||||
# The both-empty comparison, head on: an active entry whose releaseId is "".
|
||||
# `[ "$active" = "$rid" ]` with both empty is TRUE and would report success on a
|
||||
# release that does not exist. Non-emptiness is proven before the comparison.
|
||||
out=$(T_PUBLISH_BODY="$OK_PUB" T_LIST_BODY='[{"releaseId":"","active":true}]' run "$site"); rc=$?
|
||||
t "an active release with an EMPTY id is refused" 1 $rc "carries no releaseId" "$out"
|
||||
out=$(T_PUBLISH_BODY="$OK_PUB" T_LIST_BODY='[{"active":true}]' run "$site"); rc=$?
|
||||
t "an active release with NO id field is refused" 1 $rc "carries no releaseId" "$out"
|
||||
|
||||
# ---- the credential is required ---------------------------------------------
|
||||
out=$(PATH="$shim:$PATH" HANZO_API_TOKEN= bash "$SP" a-slug "$site" 2>&1); rc=$?
|
||||
t "a missing bearer refuses before any request" 1 $rc "HANZO_API_TOKEN is unset" "$out"
|
||||
|
||||
echo
|
||||
[ "$fail" = 0 ] && echo "sitepublish: all tests passed" || echo "sitepublish: FAILURES"
|
||||
exit "$fail"
|
||||
Executable
+161
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env bash
|
||||
# vendormark — refuse a repo that carries a site-generator vendor's branding.
|
||||
# One implementation, every caller.
|
||||
#
|
||||
# vendormark [dir] [git-range]
|
||||
#
|
||||
# WHAT THIS CATCHES
|
||||
#
|
||||
# Several sites in the estate began life as output from a hosted "prompt to
|
||||
# React app" generator. The generator does not sign its work in a comment you
|
||||
# would notice — it signs it in the SHAPE of the repo, in four places at once:
|
||||
#
|
||||
# 1. an asset folder named after the vendor, e.g. public/<vendor>-uploads/,
|
||||
# so every image the browser requests carries the vendor's name in its URL
|
||||
# 2. a build plugin dependency in package.json and the lockfiles
|
||||
# 3. an injected third-party <script> tag in index.html
|
||||
# 4. commit messages, author identities and Co-authored-by trailers written
|
||||
# by the vendor's bot
|
||||
#
|
||||
# The fourth is the one nothing else looks at, and the one that survives every
|
||||
# amount of tidying the working tree: a repo can render a perfect page and
|
||||
# still say, in `git log`, who really wrote it.
|
||||
#
|
||||
# This was not a hypothetical. A sweep of 1,065 repositories across the three
|
||||
# orgs found the mark in five shipped sites — one of them serving the vendor's
|
||||
# upload folder as its FAVICON, so the vendor's path was in the <head> of every
|
||||
# page and in every browser tab. Purging it needed a history rewrite and a
|
||||
# force-push of every branch. This gate exists so that is never needed twice.
|
||||
#
|
||||
# WHY THE CONTENT RULES ARE SO SPECIFIC
|
||||
#
|
||||
# The vendor's name is also an ordinary English adjective, and the naive gate —
|
||||
# case-insensitive substring of that word — is unshippable. Measured across all
|
||||
# 1,065 repos, the bare word appears legitimately in 20+ of them and NONE of
|
||||
# them are contaminated:
|
||||
#
|
||||
# tokenizer vocab.json (3 repos) "lovable</w>": 38565
|
||||
# English word lists words_alpha.txt, faker adjectives
|
||||
# ML training/eval corpora alpaca, kto, BAGEL eval prompts
|
||||
# x/net publicsuffix packed data a substring of concatenated domains
|
||||
# vendored prompt corpora third-party collections that quote it
|
||||
# our own marketing copy a competitor named in a case study
|
||||
#
|
||||
# A gate that reds a fifth of the estate on day one is a gate someone switches
|
||||
# off, and then we are worse off than before it existed. So the content rules
|
||||
# are only the strings that CANNOT occur in prose: a path segment, an npm
|
||||
# package name, an injected script URL, a generator meta tag, a bot's email.
|
||||
#
|
||||
# Two candidate rules were DROPPED after measuring them, and it is worth saying
|
||||
# why: `lovableproject.com` and `lovable.app` red an upstream Clerk SDK bundle,
|
||||
# which ships a list of dev-preview host suffixes naming several generators. A
|
||||
# third party's honest list of other people's hosts is not our contamination.
|
||||
# Those two strings are still refused in commit messages and git identities,
|
||||
# where no third party can put them.
|
||||
#
|
||||
# PATHS AND IDENTITIES ARE ABSOLUTE
|
||||
#
|
||||
# Content is judged narrowly; paths and git identities are not. There is no
|
||||
# legitimate reason for the vendor's name to appear in a filename we track or
|
||||
# in an author, committer or Co-authored-by line we write. Those are refused on
|
||||
# the bare word, no exceptions, because that is where the mark is load-bearing
|
||||
# and where tidying the working tree never reaches.
|
||||
#
|
||||
# THE ESCAPE HATCH
|
||||
#
|
||||
# `.vendormark-allow` — one path glob per line, `#` comments ignored. It exists
|
||||
# for exactly one honest case: a repo whose JOB is to name these vendors, such
|
||||
# as the history-scrubber rule files in hanzoai/.github, which cannot do their
|
||||
# work without spelling the string they remove. If you are reaching for it for
|
||||
# any other reason, you are about to ship the mark.
|
||||
#
|
||||
# EXIT: 0 clean, 1 mark found.
|
||||
set -uo pipefail
|
||||
|
||||
root=${1:-.}
|
||||
range=${2:-}
|
||||
cd "$root" 2>/dev/null || { echo "vendormark: no such directory: $root" >&2; exit 1; }
|
||||
git rev-parse --git-dir >/dev/null 2>&1 || exit 0 # not a repo: nothing to check
|
||||
|
||||
# PATHS: the bare vendor name, no exceptions. Add a vendor here and its
|
||||
# filenames are refused everywhere.
|
||||
VENDORS='lovable'
|
||||
|
||||
# CONTENT: only strings that cannot occur in prose — an asset folder, an npm
|
||||
# package, the injected script, a generator meta tag, the bot's address. Spelled
|
||||
# out in full rather than composed from $VENDORS, because half of them do not
|
||||
# contain the vendor's name at all. See the header for the two that were
|
||||
# measured against the estate and dropped.
|
||||
CONTENT_SIG='lovable-uploads/|lovable-tagger|@lovable\.dev|cdn\.gpteng\.co|gptengineer\.js|content="[Ll]ovable"'
|
||||
|
||||
# IDENTITIES AND MESSAGES: the bare word plus the vendor hosts, because nothing
|
||||
# outside our own commits can write these.
|
||||
IDENT_SIG='lovable|gptengineer|gpt-engineer'
|
||||
|
||||
fail=0
|
||||
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT
|
||||
|
||||
# --- allowlist ------------------------------------------------------------
|
||||
: > "$tmp/allow"
|
||||
if [ -f .vendormark-allow ]; then
|
||||
grep -vE '^\s*(#|$)' .vendormark-allow > "$tmp/allow" || true
|
||||
fi
|
||||
allowed() { # $1 = path
|
||||
[ -s "$tmp/allow" ] || return 1
|
||||
while IFS= read -r g; do
|
||||
# shellcheck disable=SC2254
|
||||
case "$1" in $g) return 0;; esac
|
||||
done < "$tmp/allow"
|
||||
return 1
|
||||
}
|
||||
|
||||
# --- 1. tracked paths -----------------------------------------------------
|
||||
git ls-files 2>/dev/null | grep -iE "$VENDORS" > "$tmp/paths" || true
|
||||
if [ -s "$tmp/paths" ]; then
|
||||
n=0
|
||||
while IFS= read -r p; do allowed "$p" || { echo " path: $p"; n=$((n+1)); }; done < "$tmp/paths"
|
||||
if [ "$n" -gt 0 ]; then
|
||||
echo "::error::${n} tracked path(s) carry a site-generator vendor's name. Every one of them is a URL the browser requests, so the vendor's branding is in the page. Move the assets to a neutral folder (public/img/) and repoint the references in the same change — a favicon or logo that a <link> or <img> depends on must be REPLACED with the real brand mark, not merely deleted."
|
||||
fail=1
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- 2. tracked content ---------------------------------------------------
|
||||
git grep -Iin -E "$CONTENT_SIG" HEAD 2>/dev/null | sed 's#^HEAD:##' > "$tmp/content" || true
|
||||
if [ -s "$tmp/content" ]; then
|
||||
n=0
|
||||
while IFS= read -r line; do
|
||||
p=${line%%:*}
|
||||
allowed "$p" || { echo " content: $(echo "$line" | cut -c1-140)"; n=$((n+1)); }
|
||||
done < "$tmp/content"
|
||||
if [ "$n" -gt 0 ]; then
|
||||
echo "::error::${n} tracked line(s) reference a site-generator vendor's asset folder, build plugin or injected script. Drop the dependency and the script tag; repoint the asset URLs."
|
||||
fail=1
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- 3. commit messages, identities, trailers -----------------------------
|
||||
# Default scope is HEAD alone, which is what a fetch-depth:1 CI checkout has and
|
||||
# is enough to refuse the commit being pushed. Pass a range (or set
|
||||
# VENDORMARK_RANGE) to judge more, e.g. origin/main..HEAD for a whole PR.
|
||||
range=${range:-${VENDORMARK_RANGE:-}}
|
||||
if [ -n "$range" ]; then set -- "$range"; else set -- -1 HEAD; fi
|
||||
git log "$@" --format='%H%x01%an <%ae>%x01%cn <%ce>%x01%B%x01%(trailers:unfold=true)%x02' 2>/dev/null \
|
||||
| tr -d '\n' | tr '\002' '\n' > "$tmp/log" || true
|
||||
if [ -s "$tmp/log" ]; then
|
||||
n=0
|
||||
while IFS= read -r c; do
|
||||
echo "$c" | grep -qiE "$IDENT_SIG" || continue
|
||||
echo " commit: $(echo "$c" | tr '\001' ' ' | cut -c1-150)"
|
||||
n=$((n+1))
|
||||
done < "$tmp/log"
|
||||
if [ "$n" -gt 0 ]; then
|
||||
echo "::error::${n} commit(s) name a site-generator vendor in the message, the author/committer identity or a Co-authored-by trailer. A working tree can be tidied; \`git log\` cannot be, short of a history rewrite and a force-push of every branch. Reword before pushing. Describe the work — \"generated placeholder assets\" — and never credit the generator."
|
||||
fail=1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$fail" -eq 0 ]; then
|
||||
echo "vendormark: OK — no site-generator branding in paths, tracked content, messages or identities"
|
||||
fi
|
||||
exit "$fail"
|
||||
Executable
+142
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env bash
|
||||
# vendormark_test.sh — the refusals AND the allowances, both pinned.
|
||||
#
|
||||
# A gate is only worth having if it is exact in both directions. A false
|
||||
# negative lets the vendor's mark back into a shipped site; a false positive
|
||||
# reds a repo that was always fine, and a gate that reds honest repos is a gate
|
||||
# someone switches off. So this suite asserts BOTH halves, and the allowance
|
||||
# half is the larger one on purpose: the vendor's name is also an ordinary
|
||||
# English adjective, and it occurs innocently in tokenizer vocabularies, word
|
||||
# lists, ML corpora, packed public-suffix data and third-party host lists all
|
||||
# over the estate.
|
||||
#
|
||||
# Offline and deterministic: temp repos, no network.
|
||||
set -uo pipefail
|
||||
BIN=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/vendormark
|
||||
pass=0; fail=0
|
||||
V=$(printf 'lo%s' 'vable') # not spelled literally, so this file does not
|
||||
# itself trip the gate it is testing
|
||||
|
||||
newrepo() {
|
||||
d=$(mktemp -d)
|
||||
git -C "$d" init -q
|
||||
git -C "$d" config user.email dev@hanzo.ai
|
||||
git -C "$d" config user.name "Hanzo Dev"
|
||||
printf 'x\n' > "$d/README.md"
|
||||
git -C "$d" add -A
|
||||
git -C "$d" commit -qm "initial"
|
||||
echo "$d"
|
||||
}
|
||||
check() { # name expected_rc dir [range]
|
||||
local name=$1 want=$2 dir=$3 range=${4:-}
|
||||
"$BIN" "$dir" $range >/dev/null 2>&1; local got=$?
|
||||
if [ "$got" = "$want" ]; then pass=$((pass+1)); echo " ok $name"
|
||||
else fail=$((fail+1)); echo " FAIL $name (want rc=$want, got rc=$got)"; fi
|
||||
}
|
||||
|
||||
echo "REFUSALS — the mark in each of the four places it hides"
|
||||
|
||||
d=$(newrepo)
|
||||
check "clean repo is green" 0 "$d"
|
||||
mkdir -p "$d/public/$V-uploads"
|
||||
printf 'PNG\n' > "$d/public/$V-uploads/28d53ec4.png"
|
||||
git -C "$d" add -A && git -C "$d" commit -qm "add an image"
|
||||
check "MUTATION: tracked path named for the vendor" 1 "$d"
|
||||
git -C "$d" rm -rq "public/$V-uploads" && git -C "$d" commit -qm "move the image"
|
||||
check "MUTATION REVERTED: green again" 0 "$d"
|
||||
rm -rf "$d"
|
||||
|
||||
d=$(newrepo)
|
||||
printf '<img src="/%s-uploads/28d53ec4.png" />\n' "$V" > "$d/index.html"
|
||||
git -C "$d" add -A && git -C "$d" commit -qm "a page"
|
||||
check "content: asset folder referenced in markup" 1 "$d"
|
||||
rm -rf "$d"
|
||||
|
||||
d=$(newrepo)
|
||||
printf '{"devDependencies":{"%s-tagger":"^1.1.3"}}\n' "$V" > "$d/package.json"
|
||||
git -C "$d" add -A && git -C "$d" commit -qm "deps"
|
||||
check "content: the vendor's build plugin in package.json" 1 "$d"
|
||||
rm -rf "$d"
|
||||
|
||||
d=$(newrepo)
|
||||
printf '<script src="https://cdn.gpteng.co/gptengineer.js"></script>\n' > "$d/index.html"
|
||||
git -C "$d" add -A && git -C "$d" commit -qm "a page"
|
||||
check "content: the vendor's injected script tag" 1 "$d"
|
||||
rm -rf "$d"
|
||||
|
||||
d=$(newrepo)
|
||||
printf '<meta name="generator" content="Lovable" />\n' > "$d/index.html"
|
||||
git -C "$d" add -A && git -C "$d" commit -qm "a page"
|
||||
check "content: the generator meta tag" 1 "$d"
|
||||
rm -rf "$d"
|
||||
|
||||
d=$(newrepo)
|
||||
printf 'y\n' > "$d/a.txt"; git -C "$d" add -A
|
||||
git -C "$d" commit -qm "Update $V project template"
|
||||
check "MUTATION: the vendor named in a commit message" 1 "$d"
|
||||
git -C "$d" commit -q --amend -m "update the project template"
|
||||
check "MUTATION REVERTED: reworded message is green" 0 "$d"
|
||||
rm -rf "$d"
|
||||
|
||||
d=$(newrepo)
|
||||
printf 'y\n' > "$d/a.txt"; git -C "$d" add -A
|
||||
# --no-verify ON PURPOSE. A commit-msg hook on the workstation
|
||||
# (~/.githooks/commit-msg) already strips vendor co-author trailers, and with it
|
||||
# enabled this case cannot be constructed — the hook silently rewrites the
|
||||
# trailer to ours and the assertion passes for the wrong reason. That hook is
|
||||
# per-machine: it does not run on a runner, on a teammate's laptop, or on a
|
||||
# commit made through the GitHub web UI. This gate is the layer that does. So
|
||||
# the test bypasses the hook to prove the GATE catches what the hook would have.
|
||||
git -C "$d" commit -q --no-verify -m "a change
|
||||
|
||||
Co-authored-by: $V bot <bot@$V.dev>"
|
||||
check "MUTATION: Co-authored-by credits the vendor" 1 "$d"
|
||||
git -C "$d" commit -q --amend --no-verify -m "a change"
|
||||
check "MUTATION REVERTED: trailer dropped is green" 0 "$d"
|
||||
rm -rf "$d"
|
||||
|
||||
d=$(newrepo)
|
||||
printf 'y\n' > "$d/a.txt"; git -C "$d" add -A
|
||||
git -C "$d" -c user.name="$V" -c user.email="bot@$V.dev" commit -qm "a change"
|
||||
check "MUTATION: the vendor as author identity" 1 "$d"
|
||||
rm -rf "$d"
|
||||
|
||||
d=$(newrepo)
|
||||
printf 'y\n' > "$d/a.txt"; git -C "$d" add -A; git -C "$d" commit -qm "clean subject"
|
||||
printf 'z\n' > "$d/b.txt"; git -C "$d" add -A; git -C "$d" commit -qm "Visual edit in $V"
|
||||
printf 'w\n' > "$d/c.txt"; git -C "$d" add -A; git -C "$d" commit -qm "clean again"
|
||||
check "default scope (HEAD only) misses an older bad message" 0 "$d"
|
||||
check "explicit range catches it" 1 "$d" "HEAD~3..HEAD"
|
||||
rm -rf "$d"
|
||||
|
||||
echo
|
||||
echo "ALLOWANCES — the same word, innocently, as it really occurs in the estate"
|
||||
|
||||
d=$(newrepo)
|
||||
printf ' "%s</w>": 38565,\n' "$V" > "$d/vocab.json"
|
||||
printf '%s\n' "$V" > "$d/words_alpha.txt"
|
||||
printf 'Tell me a story about a %s character.\n' "$V" > "$d/harmless.txt"
|
||||
printf 'caseStudy: %s and v0 activate users by turning a prompt into an app\n' "${V^}" > "$d/guide.yaml"
|
||||
printf 'mail2%s.com\n' "$V" > "$d/generic_emails.txt"
|
||||
printf 'let e=[".%s.app",".%sproject.com",".webcontainer-api.io"];\n' "$V" "$V" > "$d/clerk-bundle.js"
|
||||
printf 'A collection of UI components. Integrate them in v0, %s, Bolt.\n' "${V^}" > "$d/registries.json"
|
||||
git -C "$d" add -A && git -C "$d" commit -qm "corpora, word lists and third-party bundles"
|
||||
check "tokenizer vocab / word list / ML corpus / marketing prose / blocklist / upstream host list" 0 "$d"
|
||||
rm -rf "$d"
|
||||
|
||||
echo
|
||||
echo "ESCAPE HATCH — a repo whose job is to name the vendor"
|
||||
|
||||
d=$(newrepo)
|
||||
mkdir -p "$d/tools/rules"
|
||||
printf 'Hanzo Dev <dev@hanzo.ai> <bot@%s.dev>\n' "$V" > "$d/tools/rules/mailmap.txt"
|
||||
git -C "$d" add -A && git -C "$d" commit -qm "scrubber rules"
|
||||
check "scrubber rule file is refused by default" 1 "$d"
|
||||
printf 'tools/rules/*\n' > "$d/.vendormark-allow"
|
||||
git -C "$d" add -A && git -C "$d" commit -qm "declare the rule files"
|
||||
check "...and allowed once declared in .vendormark-allow" 0 "$d"
|
||||
rm -rf "$d"
|
||||
|
||||
echo
|
||||
echo "vendormark_test: $pass passed, $fail failed"
|
||||
[ "$fail" -eq 0 ]
|
||||
@@ -0,0 +1,55 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"html/template"
|
||||
)
|
||||
|
||||
// brand.go — where this page's design values come from.
|
||||
//
|
||||
// They come from @hanzo/brand, the one place the fleet's palette, radii, type
|
||||
// scale and spacing are defined, and they arrive as that package's OWN
|
||||
// published artifact rather than as hex codes retyped here. The distinction is
|
||||
// the entire point. Until this file existed the template carried its own
|
||||
// :root block, and being a hand-copy it had already drifted off the house:
|
||||
// the status colours were GitHub Primer's (#3fb950 / #f85149 / #d29922) where
|
||||
// the house says #10b981 / #ef4444 / #f59e0b, the surface blacks were each a
|
||||
// shade wrong (#0b0b0d against --surface-0 #080808), and the hairline border
|
||||
// was a solid #25252b where the house hairline is a 6%-white wash. Only the
|
||||
// accent survived intact. A palette that is copied is a palette that diverges.
|
||||
//
|
||||
// Vendored, not fetched at build time, and compiled in rather than served off
|
||||
// disk. @hanzo/brand publishes this file as a plain custom-property sheet
|
||||
// (`exports["./styles/*"]`, documented for a bare <link>), so consuming it
|
||||
// costs no npm, no bundler and no React — the image build stays `go build`
|
||||
// against an empty go.mod, and the page stays one request that returns the
|
||||
// answer. That matters here more than anywhere: this dashboard is read when
|
||||
// the build system is broken, which is the worst possible moment for it to
|
||||
// need the build system in order to draw itself.
|
||||
//
|
||||
// Refreshing is a deliberate, reviewed act — fetch, then update the pin:
|
||||
//
|
||||
// curl -sSfo brand/variables.css https://unpkg.com/@hanzo/brand@<version>/styles/variables.css
|
||||
//
|
||||
//go:embed brand/variables.css
|
||||
var brandCSS string
|
||||
|
||||
// brandCSSVersion and brandCSSSHA256 record WHICH @hanzo/brand the bytes above
|
||||
// are, and a test rejects any other bytes. This is go.sum's argument, not
|
||||
// ceremony: without it, "just darken that one border" is a one-character local
|
||||
// edit that silently restores the second source of truth this file removed, and
|
||||
// nothing would ever catch it.
|
||||
const (
|
||||
brandCSSVersion = "1.4.5"
|
||||
brandCSSSHA256 = "941dfc0080343d25dc1ef2cd780290a2d8fe6cbd81136912281902f2e8e7741f"
|
||||
)
|
||||
|
||||
// dashboardCSS is what this page adds on top: layout, not design. Every colour,
|
||||
// radius and size in it is a var() into the sheet above.
|
||||
//
|
||||
//go:embed dashboard.css
|
||||
var dashboardCSS string
|
||||
|
||||
// pageCSS is the <style> body: tokens first, then the rules that spend them.
|
||||
// template.CSS because these are two compile-time constants, never input.
|
||||
func pageCSS() template.CSS { return template.CSS(brandCSS + dashboardCSS) }
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* @hanzo/brand CSS Variables
|
||||
*
|
||||
* Hanzo is monochrome — the brand is ink, paper, and a neutral grayscale.
|
||||
* There is no brand hue; the brand color is the ink (dark) / paper (light).
|
||||
*
|
||||
* Usage:
|
||||
* @import '@hanzo/brand/styles/variables.css';
|
||||
* or link: <link rel="stylesheet" href="https://unpkg.com/@hanzo/brand/styles/variables.css">
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* ===== Hanzo Brand (Monochrome: Hanzo Black ↔ Hanzo White) ===== */
|
||||
--hanzo-black: #0a0a0b;
|
||||
--hanzo-black-rgb: 10, 10, 11;
|
||||
--hanzo-white: #ffffff;
|
||||
--hanzo-white-rgb: 255, 255, 255;
|
||||
--hanzo-mono-50: #fafafa;
|
||||
--hanzo-mono-100: #f5f5f5;
|
||||
--hanzo-mono-200: #e5e5e5;
|
||||
--hanzo-mono-300: #d4d4d4;
|
||||
--hanzo-mono-400: #a3a3a3;
|
||||
--hanzo-mono-500: #737373;
|
||||
--hanzo-mono-600: #525252;
|
||||
--hanzo-mono-700: #404040;
|
||||
--hanzo-mono-800: #262626;
|
||||
--hanzo-mono-900: #171717;
|
||||
--hanzo-mono-950: #0a0a0a;
|
||||
|
||||
/* ===== Accent — the ONE Hanzo accent: PURPLE (palette = White · Gray · Purple).
|
||||
The monochrome base stays (primary action = white, neutrals = gray); purple is
|
||||
the single interactive/brand accent — links, active, focus, selection. NO blue,
|
||||
green, or orange. White-label tenants override --hanzo-accent per host so
|
||||
lux/zoo/pars never inherit Hanzo purple. ===== */
|
||||
--hanzo-accent: #8b5cf6; /* violet-500 */
|
||||
--hanzo-accent-hover: #7c3aed; /* violet-600 */
|
||||
--hanzo-accent-muted: #a78bfa; /* violet-400 — accent text on dark */
|
||||
--hanzo-accent-soft: rgba(139, 92, 246, 0.12); /* subtle fill / selected row */
|
||||
--hanzo-accent-rgb: 139, 92, 246;
|
||||
|
||||
/* ===== Layered surface blacks (Builder v2 — no gray panels; each subtly different) ===== */
|
||||
--surface-0: #080808; /* app background */
|
||||
--surface-1: #0d0d0d; /* panels */
|
||||
--surface-2: #111111; /* raised */
|
||||
--surface-3: #171717; /* controls / hover */
|
||||
|
||||
/* ===== Hairline border — 1px, almost invisible (no thick outlines) ===== */
|
||||
--border-hairline: rgba(255, 255, 255, 0.06);
|
||||
--border-hairline-strong: rgba(255, 255, 255, 0.1);
|
||||
|
||||
/* ===== Semantic radius (Builder v2): cards 8 · controls/toolbar 10 · preview/panels 12 ===== */
|
||||
--radius-card: 0.5rem; /* 8px */
|
||||
--radius-control: 0.625rem; /* 10px — buttons, toolbar, inputs */
|
||||
--radius-panel: 0.75rem; /* 12px — preview, large panels */
|
||||
|
||||
/* ===== Semantic type roles (Builder v2): heading 20 · body 14 · secondary 12 ===== */
|
||||
--text-heading: 1.25rem; /* 20px @ 600 */
|
||||
--text-body: 0.875rem; /* 14px @ 400 */
|
||||
--text-secondary: 0.75rem; /* 12px @ 400/500 */
|
||||
|
||||
/* ===== Semantic Aliases (monochrome; flips with scheme) ===== */
|
||||
--brand: var(--hanzo-black);
|
||||
--brand-light: var(--hanzo-mono-800);
|
||||
--brand-dark: #000000;
|
||||
--brand-hover: var(--hanzo-mono-900);
|
||||
--brand-secondary: var(--hanzo-mono-600);
|
||||
|
||||
/* ===== Dark Theme Backgrounds ===== */
|
||||
--bg-primary: #0a0a0a;
|
||||
--bg-secondary: #141414;
|
||||
--bg-tertiary: #1a1a1a;
|
||||
--bg-card: rgba(23, 23, 23, 0.5);
|
||||
|
||||
/* ===== Light Theme Backgrounds ===== */
|
||||
--bg-light: #ffffff;
|
||||
--bg-light-secondary: #fafafa;
|
||||
--bg-light-tertiary: #f5f5f5;
|
||||
|
||||
/* ===== Borders ===== */
|
||||
--border: #262626;
|
||||
--border-light: #e5e5e5;
|
||||
--border-focus: var(--hanzo-black);
|
||||
|
||||
/* ===== Text Colors (Dark Theme) ===== */
|
||||
--text-primary: #fafafa;
|
||||
--text-secondary: #a3a3a3;
|
||||
--text-muted: #737373;
|
||||
--text-disabled: #525252;
|
||||
|
||||
/* ===== Text Colors (Light Theme) ===== */
|
||||
--text-light-primary: #0a0a0b;
|
||||
--text-light-secondary: #525252;
|
||||
--text-light-muted: #737373;
|
||||
|
||||
/* ===== Neutral Scale ===== */
|
||||
--neutral-0: #ffffff;
|
||||
--neutral-50: #fafafa;
|
||||
--neutral-100: #f5f5f5;
|
||||
--neutral-200: #e5e5e5;
|
||||
--neutral-300: #d4d4d4;
|
||||
--neutral-400: #a3a3a3;
|
||||
--neutral-500: #737373;
|
||||
--neutral-600: #525252;
|
||||
--neutral-700: #404040;
|
||||
--neutral-800: #262626;
|
||||
--neutral-900: #171717;
|
||||
--neutral-950: #0a0a0a;
|
||||
--neutral-1000: #000000;
|
||||
|
||||
/* ===== Semantic Colors ===== */
|
||||
--success: #10b981;
|
||||
--success-light: #34d399;
|
||||
--success-dark: #059669;
|
||||
|
||||
--warning: #f59e0b;
|
||||
--warning-light: #fcd34d;
|
||||
--warning-dark: #d97706;
|
||||
|
||||
--error: #ef4444;
|
||||
--error-light: #f87171;
|
||||
--error-dark: #dc2626;
|
||||
|
||||
--info: #3b82f6;
|
||||
--info-light: #60a5fa;
|
||||
--info-dark: #2563eb;
|
||||
|
||||
/* ===== Gradients ===== */
|
||||
--gradient-brand: linear-gradient(135deg, var(--hanzo-mono-800) 0%, var(--hanzo-black) 100%);
|
||||
--gradient-accent: linear-gradient(135deg, var(--hanzo-black) 0%, #000000 100%);
|
||||
--gradient-dark: linear-gradient(135deg, #0a0a0b 0%, #262626 100%);
|
||||
|
||||
/* ===== Spacing ===== */
|
||||
--space-1: 0.25rem;
|
||||
--space-2: 0.5rem;
|
||||
--space-3: 0.75rem;
|
||||
--space-4: 1rem;
|
||||
--space-5: 1.25rem;
|
||||
--space-6: 1.5rem;
|
||||
--space-8: 2rem;
|
||||
--space-10: 2.5rem;
|
||||
--space-12: 3rem;
|
||||
--space-16: 4rem;
|
||||
--space-20: 5rem;
|
||||
--space-24: 6rem;
|
||||
|
||||
/* ===== Border Radius ===== */
|
||||
--radius-sm: 0.125rem;
|
||||
--radius: 0.25rem;
|
||||
--radius-md: 0.375rem;
|
||||
--radius-lg: 0.5rem;
|
||||
--radius-xl: 0.75rem;
|
||||
--radius-2xl: 1rem;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* ===== Shadows ===== */
|
||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
|
||||
|
||||
/* ===== Transitions ===== */
|
||||
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition: 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
/* ===== Typography ===== */
|
||||
--font-sans: 'Geist Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'Geist Mono', ui-monospace, Monaco, monospace;
|
||||
|
||||
/* ===== Font Size scale (mirrors typography.ts `fontSize`) =====
|
||||
TIGHT app-first default — the compact developer-app register (linear.app /
|
||||
vercel.com), NOT a roomy marketing scale. Base is 14px, nav 13px, labels 11px.
|
||||
Every surface that imports @hanzo/brand inherits this; a brand/tenant can
|
||||
override any --font-size-* on :root to retune density on demand. */
|
||||
--font-size-xs: 0.6875rem; /* 11px — eyebrows / section labels */
|
||||
--font-size-sm: 0.8125rem; /* 13px — nav labels, dense body */
|
||||
--font-size-base: 0.875rem; /* 14px — base app text (was 16px) */
|
||||
--font-size-lg: 0.9375rem; /* 15px */
|
||||
--font-size-xl: 1.0625rem; /* 17px */
|
||||
--font-size-2xl: 1.3125rem; /* 21px */
|
||||
--font-size-3xl: 1.625rem; /* 26px */
|
||||
--font-size-4xl: 2rem; /* 32px */
|
||||
--font-size-5xl: 2.5rem; /* 40px */
|
||||
--font-size-6xl: 3.25rem; /* 52px */
|
||||
--font-size-7xl: 4rem; /* 64px */
|
||||
--font-size-8xl: 5.25rem; /* 84px */
|
||||
--font-size-9xl: 7rem; /* 112px */
|
||||
|
||||
/* ===== Z-index ladder (mirrors tokens.ts `zIndex`) ===== */
|
||||
--z-0: 0;
|
||||
--z-10: 10;
|
||||
--z-20: 20;
|
||||
--z-30: 30;
|
||||
--z-40: 40;
|
||||
--z-50: 50;
|
||||
--z-dropdown: 100;
|
||||
--z-sticky: 200;
|
||||
--z-overlay: 300;
|
||||
--z-modal: 400;
|
||||
--z-popover: 500;
|
||||
--z-tooltip: 600;
|
||||
--z-notification: 700;
|
||||
}
|
||||
|
||||
/* Dark theme (default for Hanzo) */
|
||||
[data-theme="dark"],
|
||||
.dark {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* Light theme */
|
||||
[data-theme="light"],
|
||||
.light {
|
||||
--bg-primary: var(--bg-light);
|
||||
--bg-secondary: var(--bg-light-secondary);
|
||||
--bg-tertiary: var(--bg-light-tertiary);
|
||||
--bg-card: rgba(255, 255, 255, 0.8);
|
||||
--border: var(--border-light);
|
||||
--text-primary: var(--text-light-primary);
|
||||
--text-secondary: var(--text-light-secondary);
|
||||
--text-muted: var(--text-light-muted);
|
||||
/* purple accent flips a shade deeper for contrast on paper */
|
||||
--hanzo-accent: #7c3aed;
|
||||
--hanzo-accent-hover: #6d28d9;
|
||||
--hanzo-accent-muted: #7c3aed;
|
||||
/* layered "blacks" become layered near-whites in light */
|
||||
--surface-0: #ffffff;
|
||||
--surface-1: #fafafa;
|
||||
--surface-2: #f5f5f5;
|
||||
--surface-3: #ededed;
|
||||
--border-hairline: rgba(0, 0, 0, 0.08);
|
||||
--border-hairline-strong: rgba(0, 0, 0, 0.12);
|
||||
color-scheme: light;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/* dashboard.css — this page's own rules: what is a row, what sticks, what
|
||||
collapses on a phone. It names no colour, radius or type size of its own;
|
||||
every such value is a var() into @hanzo/brand (see brand.go). That is the
|
||||
difference between consuming the design system and being a second copy of it,
|
||||
and it is asserted, not merely intended — see TestDashboardCSSNamesNoColours. */
|
||||
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--surface-0);color:var(--text-primary);
|
||||
font-family:var(--font-sans);font-size:var(--font-size-base);line-height:1.5}
|
||||
header{display:flex;align-items:center;gap:var(--space-4);
|
||||
padding:var(--space-4) var(--space-6);background:var(--surface-1);
|
||||
border-bottom:1px solid var(--border-hairline)}
|
||||
h1{margin:0;font-size:var(--font-size-lg);font-weight:600;letter-spacing:-.01em}
|
||||
h1 span{color:var(--hanzo-accent-muted)}
|
||||
.meta{margin-left:auto;color:var(--text-secondary);font-size:var(--font-size-sm);text-align:right}
|
||||
|
||||
.strip{display:flex;gap:var(--space-2);padding:var(--space-4) var(--space-6);flex-wrap:wrap}
|
||||
.chip{padding:var(--space-2) var(--space-3);background:var(--surface-1);
|
||||
border:1px solid var(--border-hairline);border-radius:var(--radius-card);
|
||||
font-size:var(--font-size-sm);color:var(--text-secondary)}
|
||||
.chip b{color:var(--text-primary);font-weight:600}
|
||||
.chip.ok b{color:var(--success)} .chip.fail b{color:var(--error)}
|
||||
.chip.run b{color:var(--warning)} .chip.cancel b{color:var(--text-muted)}
|
||||
|
||||
nav{display:flex;gap:var(--space-2);padding:0 var(--space-6) var(--space-4);flex-wrap:wrap}
|
||||
nav a{padding:var(--space-1) var(--space-3);background:var(--surface-1);
|
||||
border:1px solid var(--border-hairline);border-radius:var(--radius-full);
|
||||
color:var(--text-secondary);text-decoration:none;font-size:var(--font-size-sm)}
|
||||
/* --hanzo-accent-soft is the house "selected row" fill; the active tab is the
|
||||
one place on this page that is a selection, so it is the one place it is used. */
|
||||
nav a.on{border-color:var(--hanzo-accent);background:var(--hanzo-accent-soft);color:var(--text-primary)}
|
||||
nav .who{margin-left:auto;align-self:center;color:var(--text-muted);font-size:var(--font-size-sm)}
|
||||
|
||||
/* The stale banner tints its own border colour rather than introducing an amber
|
||||
of its own — @hanzo/brand ships no warning-surface token, and inventing one
|
||||
here is exactly the drift this file exists to stop. */
|
||||
.warn{margin:0 var(--space-6) var(--space-4);padding:var(--space-3) var(--space-4);
|
||||
border:1px solid var(--warning);border-radius:var(--radius-card);
|
||||
background:color-mix(in srgb, var(--warning) 10%, transparent);
|
||||
color:var(--warning-light);font-size:var(--font-size-sm)}
|
||||
|
||||
table{width:100%;border-collapse:collapse}
|
||||
th{position:sticky;top:0;background:var(--surface-1);text-align:left;
|
||||
font-size:var(--font-size-xs);text-transform:uppercase;letter-spacing:.06em;
|
||||
font-weight:600;color:var(--text-muted);padding:var(--space-2) var(--space-3);
|
||||
border-bottom:1px solid var(--border-hairline-strong)}
|
||||
td{padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--border-hairline);
|
||||
vertical-align:top}
|
||||
/* The table is full-bleed but its text has to sit on the same gutter as the
|
||||
header, chips and nav above it, which are all --space-6 in. */
|
||||
th:first-child,td:first-child{padding-left:var(--space-6)}
|
||||
th:last-child,td:last-child{padding-right:var(--space-6)}
|
||||
tr:hover td{background:var(--surface-2)}
|
||||
a{color:inherit}
|
||||
|
||||
.dot{display:inline-block;width:8px;height:8px;border-radius:var(--radius-full);
|
||||
margin-right:var(--space-2)}
|
||||
.dot.success{background:var(--success)} .dot.failure{background:var(--error)}
|
||||
.dot.running{background:var(--warning);animation:p 1.4s ease-in-out infinite}
|
||||
.dot.cancelled{background:var(--text-disabled)}
|
||||
@keyframes p{50%{opacity:.35}}
|
||||
|
||||
.repo{font-weight:600}
|
||||
.org{color:var(--text-muted)}
|
||||
.title{color:var(--text-secondary);max-width:42ch;
|
||||
overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.mono{font-family:var(--font-mono);font-size:var(--font-size-sm);color:var(--text-secondary)}
|
||||
.empty{padding:var(--space-12) var(--space-6);text-align:center;color:var(--text-secondary)}
|
||||
footer{padding:var(--space-4) var(--space-6);color:var(--text-muted);
|
||||
font-size:var(--font-size-sm);border-top:1px solid var(--border-hairline)}
|
||||
@media(max-width:760px){.hide-sm{display:none}}
|
||||
@@ -0,0 +1,178 @@
|
||||
# Hanzo CI — this repo's own build, driven by this repo's own reusable workflow.
|
||||
#
|
||||
# hanzoai/ci is two things that belong together: the reusable pipeline every
|
||||
# other repo imports (.hanzo/workflows/build.yml), and ci.hanzo.ai, the
|
||||
# dashboard that shows what that pipeline did. So the dashboard image is built
|
||||
# by the pipeline it reports on — if the pipeline breaks, the thing that would
|
||||
# tell you cannot ship, which is the correct and honest coupling.
|
||||
#
|
||||
# Until now there was no self-build at all: build.yml is workflow_call-only, so
|
||||
# the v0.1.0 image was produced out of band and there was no repeatable way to
|
||||
# cut a second one.
|
||||
images:
|
||||
- name: ci
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
repo: ghcr.io/hanzoai/ci
|
||||
|
||||
test:
|
||||
- name: go-vet
|
||||
run: |
|
||||
set -e
|
||||
export GOWORK=off
|
||||
go vet ./...
|
||||
- name: go-unit
|
||||
# The whole tree, not a named list — an allowlist stops covering whatever is
|
||||
# added after it is written. Small repo; ./... costs nothing.
|
||||
#
|
||||
# scope_test.go is the load-bearing one: it asserts the surface refuses a
|
||||
# request with no X-Org-Id and that `?org=` can only narrow. This service
|
||||
# shipped once with those properties absent and disclosed every org's build
|
||||
# metadata to the internet, so a red gate here must block the image.
|
||||
#
|
||||
# render_test.go is the other one that has to stay green: it pins the
|
||||
# vendored @hanzo/brand sheet to the hash of the version it claims to be and
|
||||
# rejects any colour the page names for itself. Both gates are offline —
|
||||
# checking that we use one design system costs this pipeline no npm, no
|
||||
# registry and no network.
|
||||
run: |
|
||||
set -e
|
||||
export GOWORK=off
|
||||
go test -count=1 ./...
|
||||
- name: build-yml-is-one-file
|
||||
# The reusable pipeline is published at TWO paths because two forges read two
|
||||
# directories — github.com only `.github/workflows`, git.hanzo.ai only
|
||||
# `.hanzo/workflows`. That is one artifact spelled twice, and nothing until
|
||||
# now asserted it: the `.hanzo` copy had drifted nine lines (a truncated
|
||||
# second `on:` block and a duplicate `name:` key) and no reader could see it,
|
||||
# because the only consumer of that copy is a forge no push from here reaches.
|
||||
#
|
||||
# The ONLY legitimate difference is the path each names for itself, so
|
||||
# normalise that one spelling and demand byte equality of the rest. A gate
|
||||
# that allowed "the important parts match" would be a gate that cannot say
|
||||
# what important means.
|
||||
run: |
|
||||
set -e
|
||||
norm() { sed 's|\.hanzo/workflows/build\.yml|.github/workflows/build.yml|g' "$1"; }
|
||||
if ! diff -u <(norm .github/workflows/build.yml) <(norm .hanzo/workflows/build.yml); then
|
||||
echo "::error::the two published copies of the reusable have diverged. They are one file at two paths — edit both, or the forge runs a pipeline github.com has never seen."
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: .github and .hanzo copies are one file ($(wc -l < .github/workflows/build.yml) lines)"
|
||||
- name: gate-runs-before-delegate
|
||||
# `mode: delegate` chooses WHERE the image is built. It must never choose
|
||||
# WHETHER the commit was tested. It did: the delegate step was evaluated
|
||||
# fourth, ahead of every toolchain and ahead of `test:`, and every other step
|
||||
# carried `if: inputs.mode != 'delegate'` — so a delegated run skipped the
|
||||
# gate along with the build and finished green in seconds having proven
|
||||
# nothing. The seam was right and the ORDER was wrong, which is the kind of
|
||||
# defect that reads as correct in every diff that touches one step at a time.
|
||||
#
|
||||
# Two assertions, because either one alone can be satisfied while the gate
|
||||
# still does not run: the delegate POST must come AFTER the test gate, and
|
||||
# the test gate must not be delegate-guarded.
|
||||
run: |
|
||||
set -e
|
||||
f=.github/workflows/build.yml
|
||||
t=$(grep -n '^ - name: Test (per hanzo.yml)$' "$f" | cut -d: -f1)
|
||||
d=$(grep -n '^ - name: Delegate build to platform (mode=delegate)$' "$f" | cut -d: -f1)
|
||||
[ -n "$t" ] && [ -n "$d" ] || { echo "::error::cannot find the test gate ($t) or the delegate step ($d) — a rename broke this assertion, fix the assertion"; exit 1; }
|
||||
[ "$d" -gt "$t" ] || { echo "::error::the delegate step (line $d) runs BEFORE the test gate (line $t) — a delegated build would ship an ungated commit"; exit 1; }
|
||||
if sed -n "${t}p;$((t+1)),$((t+80))p" "$f" | grep -m1 '^ if: ' | grep -q "mode != 'delegate'"; then
|
||||
echo "::error::the test gate is guarded by \`inputs.mode != 'delegate'\` — delegate would skip it"; exit 1
|
||||
fi
|
||||
echo "OK: test gate at line $t, delegate POST at line $d, gate not delegate-guarded"
|
||||
- name: publishable
|
||||
# bin/publishable refuses a `build_secrets` name that has not declared itself
|
||||
# public. That matters because a build_secret is baked in as a --build-arg,
|
||||
# which `docker history` prints to anyone who can pull — so the key is a
|
||||
# publishing decision wearing the word "secret". Until this, the only check
|
||||
# in the fleet lived inside hanzoai/ui's own Dockerfile, for one image.
|
||||
run: bash bin/publishable_test.sh
|
||||
- name: publishable-rule-is-one-rule
|
||||
# The rule is spelled twice on purpose, and this is what keeps it one rule.
|
||||
#
|
||||
# build.yml cannot call bin/publishable, because the tools checkout and the
|
||||
# workflow resolve from DIFFERENT places: a caller pins the workflow at a ref
|
||||
# its own forge resolves, while the tools step derives its ref from
|
||||
# GITHUB_WORKFLOW_REF — which git.hanzo.ai does not set, so it falls back to
|
||||
# `v1` and clones github.com. Measured on run 36473: `derived ref=v1`,
|
||||
# `cloned https://github.com/hanzoai/ci@v1`. So a step calling a NEW file
|
||||
# under $CI_HOME is broken on every forge run until a tag moves on a host the
|
||||
# caller never named. The pipeline has to carry its own rule.
|
||||
#
|
||||
# Which leaves the pattern in two files, which is the same shape as the two
|
||||
# copies of build.yml and gets the same treatment: demand they are identical
|
||||
# and let the gate, not a reader, be the thing that notices.
|
||||
run: |
|
||||
set -e
|
||||
a=$(grep -oE '^[[:space:]]*PUBLISHABLE_\*\|[^)]*\)' .github/workflows/build.yml | tr -d '[:space:]')
|
||||
b=$(grep -oE '^[[:space:]]*PUBLISHABLE_\*\|[^)]*\)' bin/publishable | tr -d '[:space:]')
|
||||
[ -n "$a" ] && [ -n "$b" ] || { echo "::error::publishable rule not found in build.yml ($a) or bin/publishable ($b) — an edit renamed it, fix this assertion"; exit 1; }
|
||||
[ "$a" = "$b" ] || { echo "::error::the publishable rule differs between build.yml and bin/publishable — build.yml is what runs, bin/publishable is what is tested, so a difference means the tested rule is not the enforced one:
|
||||
build.yml: $a
|
||||
bin/publishable: $b"; exit 1; }
|
||||
echo "OK: one publishable rule, two spellings — $a"
|
||||
- name: imgver
|
||||
# bin/imgver decides the version EVERY image in the fleet publishes — this
|
||||
# workflow's build lane calls it, and so does the imgver composite action the
|
||||
# repos that hand-roll their own deploy.yml use. A wrong number here is one
|
||||
# tag covering two digests, which a node running imagePullPolicy:
|
||||
# IfNotPresent never picks up and no reader can see. Offline and
|
||||
# deterministic: the registry floor is injected, so it needs no network.
|
||||
run: bash bin/imgver_test.sh
|
||||
- name: vendormark
|
||||
# bin/vendormark is the gate the build lane runs against every checkout: it
|
||||
# refuses a site-generator vendor's branding in a tracked path, in tracked
|
||||
# content, in a commit message, in an author identity or in a Co-authored-by
|
||||
# trailer. Five shipped sites carried it — one serving the vendor's upload
|
||||
# folder as its favicon — and clearing them took a history rewrite and a
|
||||
# force-push of every branch.
|
||||
#
|
||||
# The suite pins the refusals AND the allowances, and the allowances are the
|
||||
# larger half on purpose. The vendor's name is also an ordinary English
|
||||
# adjective: across the 1,065 repos swept it appears innocently in tokenizer
|
||||
# vocabularies, word lists, ML corpora, packed public-suffix data and an
|
||||
# upstream Clerk bundle's list of other people's dev hosts. A gate that reds
|
||||
# a fifth of the estate is a gate someone switches off, so the content rules
|
||||
# are only strings that cannot occur in prose, and two candidate rules were
|
||||
# measured and DROPPED for redding that Clerk bundle. Offline and
|
||||
# deterministic: temp repos, no network.
|
||||
run: bash bin/vendormark_test.sh
|
||||
- name: gover
|
||||
# bin/gover is the gate the build lane runs against every Dockerfile before
|
||||
# it builds: it refuses a Go builder image older than the go.mod it
|
||||
# compiles. That failure is not hypothetical — the golang images set
|
||||
# GOTOOLCHAIN=local, so the mismatch is a hard mid-build death, and
|
||||
# hanzoai/visor v1.108.16 shipped it. A sweep of the orgs found 54 more
|
||||
# Dockerfiles already below their own go.mod.
|
||||
#
|
||||
# The gate is only worth having if it is exact in BOTH directions: a false
|
||||
# negative lets the next visor through, and a false positive blocks a build
|
||||
# that would have worked, which is how gates get skipped. So the suite pins
|
||||
# the refusals AND the allowances — a newer image than the floor is fine, an
|
||||
# alpine suffix is not a Go patch, a floating tag warns instead of failing,
|
||||
# and a multi-module repo is judged by its NEAREST go.mod. Offline and
|
||||
# deterministic: temp dirs, no registry, no network.
|
||||
run: bash bin/gover_test.sh
|
||||
- name: sitepublish
|
||||
# bin/sitepublish is the whole of the `site:` lane. The lane it replaced was
|
||||
# dead: it staged with `mc mirror` behind S3_ADMIN_ACCESS_KEY/SECRET_KEY,
|
||||
# names KMS holds for no org, so it failed closed for every caller — and of
|
||||
# the 155 hanzo.yml files across the three orgs, not one declared a `site:`.
|
||||
#
|
||||
# The suite is weighted toward REFUSALS because the failure that matters here
|
||||
# is a green publish that verified nothing — indistinguishable from a working
|
||||
# one until a site quietly stops updating. Three defects of exactly that
|
||||
# shape are pinned by name: an object-shaped read of a body that is not an
|
||||
# object, `.releases[]` against the BARE ARRAY the list route returns (which
|
||||
# resolves nothing and so can never fail for its stated reason), and
|
||||
# `[ "$a" = "$b" ]` on two empty strings, which is TRUE. Reintroducing them
|
||||
# turns 3, 9 and 1 of these tests red respectively. Offline and
|
||||
# deterministic: a curl shim answers the routes from fixtures, no network.
|
||||
run: bash bin/sitepublish_test.sh
|
||||
|
||||
# No `deploy:` ON PURPOSE. Rollout is a reviewed tag pin in hanzoai/universe
|
||||
# (infra/k8s/operator/crs/ci.yaml), the same rule cloud and git follow: a
|
||||
# pipeline that both builds and rolls itself out can put an unreviewed image on
|
||||
# a public host, and cd.hanzo.ai's selfHeal would undo a direct patch anyway.
|
||||
@@ -0,0 +1,495 @@
|
||||
// ci — the dashboard behind ci.hanzo.ai.
|
||||
//
|
||||
// It owns no build state. Run truth lives in Hanzo Git (git.hanzo.ai), which
|
||||
// schedules the jobs and holds every log; this reads that and presents it. The
|
||||
// alternative — a CI service with its own run database — would put two answers
|
||||
// to "did the build pass" in the fleet, and the one users look at would be the
|
||||
// one that can drift. So: git.hanzo.ai is the store, ci.hanzo.ai is the view.
|
||||
//
|
||||
// This is the CI half of the pair. cd.hanzo.ai reconciles image pins from
|
||||
// hanzoai/universe and is the delivery view; the two are deliberately separate
|
||||
// surfaces over separate systems, not one console pretending build and deploy
|
||||
// are the same event.
|
||||
//
|
||||
// Tenancy is the same value everywhere: an org slug. Hanzo Git namespaces repos
|
||||
// by org, IAM issues that slug in the `owner` claim, and Hanzo CD fences
|
||||
// projects by it. Filtering here by `org` is therefore the same boundary those
|
||||
// enforce, not a parallel notion of who-sees-what.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stderr, nil))
|
||||
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
logger.Error("config", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
src := &gitSource{base: cfg.gitBase, token: cfg.gitToken, http: &http.Client{Timeout: 20 * time.Second}}
|
||||
cache := &runCache{}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
// One poller, one cache. Every viewer reads the same snapshot, so N open
|
||||
// dashboards cost Hanzo Git exactly as much as one — a dashboard that
|
||||
// fanned each page load into upstream calls is how a status page takes the
|
||||
// system it reports on down.
|
||||
go poll(ctx, logger, src, cache, cfg)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
// Liveness only: up means "serving". Readiness deliberately does NOT
|
||||
// gate on having a snapshot — a Hanzo Git outage must show as a stale
|
||||
// dashboard saying so, not as ci.hanzo.ai disappearing from the LB too.
|
||||
writeJSON(w, http.StatusOK, map[string]any{"status": "ok"})
|
||||
})
|
||||
mux.HandleFunc("/v1/runs", func(w http.ResponseWriter, r *http.Request) {
|
||||
v, ok := requireViewer(w, r, cfg.adminOrg)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
snap := cache.get()
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"runs": v.visible(snap.Runs, r.URL.Query().Get("org")),
|
||||
"fetchedAt": snap.FetchedAt,
|
||||
"stale": snap.stale(cfg.staleAfter),
|
||||
"sourceErr": snap.errString(),
|
||||
"repos": snap.Repos,
|
||||
"orgs": v.orgs(snap.Runs),
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
v, ok := requireViewer(w, r, cfg.adminOrg)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
renderDashboard(w, cache.get(), v, r.URL.Query().Get("org"), cfg)
|
||||
})
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: cfg.listen,
|
||||
Handler: mux,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
sh, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
_ = srv.Shutdown(sh)
|
||||
}()
|
||||
|
||||
logger.Info("ci dashboard listening", "addr", cfg.listen, "source", cfg.gitBase, "refresh", cfg.refresh.String())
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
logger.Error("serve", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────── config ─────────────────────────────
|
||||
|
||||
type config struct {
|
||||
listen string
|
||||
gitBase string
|
||||
// adminOrg is the ONE org whose members see across tenants. It must match
|
||||
// admin-guard's IAM_ADMIN_ORG — the guard decides who gets in, this decides
|
||||
// who sees everything, and a mismatch would silently demote the fleet view to
|
||||
// a single-org view (or, if set too wide, promote a tenant to it).
|
||||
adminOrg string
|
||||
gitToken string
|
||||
refresh time.Duration
|
||||
staleAfter time.Duration
|
||||
scanRepos int
|
||||
runsPer int
|
||||
}
|
||||
|
||||
func loadConfig() (config, error) {
|
||||
c := config{
|
||||
listen: env("CI_LISTEN", ":8080"),
|
||||
gitBase: strings.TrimRight(env("CI_GIT_BASE", "https://git.hanzo.ai"), "/"),
|
||||
adminOrg: env("CI_ADMIN_ORG", "admin"),
|
||||
gitToken: os.Getenv("CI_GIT_TOKEN"),
|
||||
scanRepos: envInt("CI_SCAN_REPOS", 60),
|
||||
runsPer: envInt("CI_RUNS_PER_REPO", 8),
|
||||
}
|
||||
c.refresh = time.Duration(envInt("CI_REFRESH_SECONDS", 45)) * time.Second
|
||||
// Stale is a multiple of refresh, not its own knob: the only meaningful
|
||||
// definition of stale is "we have missed several refreshes", and deriving
|
||||
// it means the two can never be configured into contradiction.
|
||||
c.staleAfter = 4 * c.refresh
|
||||
if c.gitToken == "" {
|
||||
return c, errors.New("CI_GIT_TOKEN required (Hanzo Git API token)")
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func env(k, def string) string {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func envInt(k string, def int) int {
|
||||
if v, err := strconv.Atoi(os.Getenv(k)); err == nil && v > 0 {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// ───────────────────────────── model ─────────────────────────────
|
||||
|
||||
// Run is the projection of a Hanzo Git workflow run this dashboard shows. It is
|
||||
// deliberately a SUBSET: the upstream object carries a dozen more fields, and
|
||||
// copying them all would make this a second schema to maintain against theirs.
|
||||
type Run struct {
|
||||
ID int64 `json:"id"`
|
||||
Org string `json:"org"`
|
||||
Repo string `json:"repo"`
|
||||
Workflow string `json:"workflow"`
|
||||
Title string `json:"title"`
|
||||
|
||||
// Status and Conclusion are BOTH required to know how a run went, and
|
||||
// reading only one is wrong in a way that looks fine. Status answers
|
||||
// "is it over" (queued | in_progress | completed); Conclusion answers
|
||||
// "how did it end" and is empty until it is over. A view that buckets on
|
||||
// Status alone sees `completed` and cannot tell a pass from a failure —
|
||||
// which is exactly the bug this pair replaced: every finished run,
|
||||
// including successes and cancellations, was being drawn as failing.
|
||||
Status string `json:"status"`
|
||||
Conclusion string `json:"conclusion"`
|
||||
|
||||
Event string `json:"event"`
|
||||
Branch string `json:"branch"`
|
||||
SHA string `json:"sha"`
|
||||
Actor string `json:"actor"`
|
||||
Number int `json:"number"`
|
||||
URL string `json:"url"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
EndedAt time.Time `json:"endedAt"`
|
||||
}
|
||||
|
||||
// Duration is zero-valued rather than negative when a run has not finished —
|
||||
// callers render "running", and a negative duration would print as one.
|
||||
func (r Run) Duration() time.Duration {
|
||||
if r.StartedAt.IsZero() || r.EndedAt.IsZero() || r.EndedAt.Before(r.StartedAt) {
|
||||
return 0
|
||||
}
|
||||
return r.EndedAt.Sub(r.StartedAt)
|
||||
}
|
||||
|
||||
type snapshot struct {
|
||||
Runs []Run `json:"runs"`
|
||||
Repos int `json:"repos"`
|
||||
FetchedAt time.Time `json:"fetchedAt"`
|
||||
Err error `json:"-"`
|
||||
}
|
||||
|
||||
func (s snapshot) stale(after time.Duration) bool {
|
||||
return s.FetchedAt.IsZero() || time.Since(s.FetchedAt) > after
|
||||
}
|
||||
|
||||
func (s snapshot) errString() string {
|
||||
if s.Err == nil {
|
||||
return ""
|
||||
}
|
||||
return s.Err.Error()
|
||||
}
|
||||
|
||||
type runCache struct {
|
||||
mu sync.RWMutex
|
||||
snap snapshot
|
||||
}
|
||||
|
||||
func (c *runCache) get() snapshot {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.snap
|
||||
}
|
||||
|
||||
// put keeps the LAST GOOD run list when a refresh fails, recording the error
|
||||
// alongside it. A failed poll must not blank the dashboard: "Hanzo Git is
|
||||
// unreachable, here is what we last saw" is strictly more useful than an empty
|
||||
// page, which reads as "nothing is building".
|
||||
func (c *runCache) put(s snapshot) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if s.Err != nil && len(s.Runs) == 0 && len(c.snap.Runs) > 0 {
|
||||
prev := c.snap
|
||||
prev.Err = s.Err
|
||||
c.snap = prev
|
||||
return
|
||||
}
|
||||
c.snap = s
|
||||
}
|
||||
|
||||
// ───────────────────────────── source ─────────────────────────────
|
||||
|
||||
type gitSource struct {
|
||||
base string
|
||||
token string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func (g *gitSource) getJSON(ctx context.Context, path string, out any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, g.base+path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "token "+g.token)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := g.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("%s: %s", path, resp.Status)
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
|
||||
type repoRef struct {
|
||||
FullName string `json:"full_name"`
|
||||
}
|
||||
|
||||
// repos returns the most recently ACTIVE repositories. Sorting by activity and
|
||||
// taking a window is the whole scan strategy: the instance mirrors ~1400 repos
|
||||
// and almost none of them built in the last hour, so walking all of them would
|
||||
// spend the entire refresh budget confirming silence.
|
||||
func (g *gitSource) repos(ctx context.Context, limit int) ([]string, error) {
|
||||
var body struct {
|
||||
Data []repoRef `json:"data"`
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("sort", "updated")
|
||||
q.Set("order", "desc")
|
||||
q.Set("limit", strconv.Itoa(limit))
|
||||
if err := g.getJSON(ctx, "/v1/repos/search?"+q.Encode(), &body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names := make([]string, 0, len(body.Data))
|
||||
for _, r := range body.Data {
|
||||
if r.FullName != "" {
|
||||
names = append(names, r.FullName)
|
||||
}
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
type apiRun struct {
|
||||
ID int64 `json:"id"`
|
||||
DisplayTitle string `json:"display_title"`
|
||||
Path string `json:"path"`
|
||||
Event string `json:"event"`
|
||||
Status string `json:"status"`
|
||||
Conclusion string `json:"conclusion"`
|
||||
HeadBranch string `json:"head_branch"`
|
||||
HeadSHA string `json:"head_sha"`
|
||||
RunNumber int `json:"run_number"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
StartedAt string `json:"started_at"`
|
||||
CompletedAt string `json:"completed_at"`
|
||||
Actor struct {
|
||||
Login string `json:"login"`
|
||||
} `json:"actor"`
|
||||
}
|
||||
|
||||
func (g *gitSource) runs(ctx context.Context, fullName string, limit int) ([]Run, error) {
|
||||
var body struct {
|
||||
WorkflowRuns []apiRun `json:"workflow_runs"`
|
||||
}
|
||||
path := fmt.Sprintf("/v1/repos/%s/actions/runs?limit=%d", fullName, limit)
|
||||
if err := g.getJSON(ctx, path, &body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
org, repo := splitFullName(fullName)
|
||||
out := make([]Run, 0, len(body.WorkflowRuns))
|
||||
for _, r := range body.WorkflowRuns {
|
||||
out = append(out, Run{
|
||||
ID: r.ID,
|
||||
Org: org,
|
||||
Repo: repo,
|
||||
Workflow: workflowOf(r.Path),
|
||||
Title: r.DisplayTitle,
|
||||
Status: r.Status,
|
||||
Conclusion: r.Conclusion,
|
||||
Event: r.Event,
|
||||
Branch: r.HeadBranch,
|
||||
SHA: shortSHA(r.HeadSHA),
|
||||
Actor: r.Actor.Login,
|
||||
Number: r.RunNumber,
|
||||
URL: r.HTMLURL,
|
||||
StartedAt: parseTime(r.StartedAt),
|
||||
EndedAt: parseTime(r.CompletedAt),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// poll refreshes the snapshot on an interval, forever.
|
||||
func poll(ctx context.Context, logger *slog.Logger, src *gitSource, cache *runCache, cfg config) {
|
||||
refresh := func() {
|
||||
rctx, cancel := context.WithTimeout(ctx, 90*time.Second)
|
||||
defer cancel()
|
||||
|
||||
names, err := src.repos(rctx, cfg.scanRepos)
|
||||
if err != nil {
|
||||
logger.Warn("repo scan failed", "err", err)
|
||||
cache.put(snapshot{FetchedAt: time.Now().UTC(), Err: err})
|
||||
return
|
||||
}
|
||||
|
||||
// Fan out, bounded. The cap is small on purpose: this is a read against
|
||||
// the forge that schedules every build in the fleet, and a dashboard is
|
||||
// never worth degrading it.
|
||||
const workers = 6
|
||||
var (
|
||||
mu sync.Mutex
|
||||
all []Run
|
||||
errs []string
|
||||
wg sync.WaitGroup
|
||||
)
|
||||
jobs := make(chan string)
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for name := range jobs {
|
||||
rs, err := src.runs(rctx, name, cfg.runsPer)
|
||||
mu.Lock()
|
||||
if err != nil {
|
||||
// A repo with Actions disabled 404s. That is normal and
|
||||
// not worth surfacing as a dashboard-level failure, so
|
||||
// it is counted, not shown.
|
||||
errs = append(errs, name)
|
||||
} else {
|
||||
all = append(all, rs...)
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
for _, n := range names {
|
||||
select {
|
||||
case jobs <- n:
|
||||
case <-rctx.Done():
|
||||
}
|
||||
}
|
||||
close(jobs)
|
||||
wg.Wait()
|
||||
|
||||
sort.Slice(all, func(i, j int) bool { return all[i].StartedAt.After(all[j].StartedAt) })
|
||||
cache.put(snapshot{Runs: all, Repos: len(names) - len(errs), FetchedAt: time.Now().UTC()})
|
||||
logger.Info("refreshed", "repos", len(names), "withRuns", len(names)-len(errs), "runs", len(all))
|
||||
}
|
||||
|
||||
refresh()
|
||||
t := time.NewTicker(cfg.refresh)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────── helpers ─────────────────────────────
|
||||
|
||||
func splitFullName(s string) (org, repo string) {
|
||||
if i := strings.IndexByte(s, '/'); i > 0 {
|
||||
return s[:i], s[i+1:]
|
||||
}
|
||||
return "", s
|
||||
}
|
||||
|
||||
// workflowOf reduces "e2e.yml@refs/heads/main" to "e2e.yml".
|
||||
func workflowOf(path string) string {
|
||||
if i := strings.IndexByte(path, '@'); i > 0 {
|
||||
return path[:i]
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func shortSHA(s string) string {
|
||||
if len(s) > 7 {
|
||||
return s[:7]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func parseTime(s string) time.Time {
|
||||
if s == "" {
|
||||
return time.Time{}
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, s)
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
// Hanzo Git reports an unset timestamp as the Unix epoch rather than null;
|
||||
// treated as absent so the UI shows "—" instead of 1970.
|
||||
if t.Year() < 2000 {
|
||||
return time.Time{}
|
||||
}
|
||||
return t.UTC()
|
||||
}
|
||||
|
||||
func filterByOrg(runs []Run, org string) []Run {
|
||||
if org == "" {
|
||||
return runs
|
||||
}
|
||||
out := make([]Run, 0, len(runs))
|
||||
for _, r := range runs {
|
||||
if r.Org == org {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func orgsOf(runs []Run) []string {
|
||||
seen := map[string]bool{}
|
||||
for _, r := range runs {
|
||||
if r.Org != "" {
|
||||
seen[r.Org] = true
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for o := range seen {
|
||||
out = append(out, o)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// render.go — the HTML view. Server-rendered on purpose: this page is a table
|
||||
// of build results, and a client-side app would ship a bundle, a fetch layer
|
||||
// and a loading state to show the same rows a second later. The dashboard also
|
||||
// has to be readable when the thing it reports on is broken, which is exactly
|
||||
// when a build pipeline for its own frontend is the wrong dependency.
|
||||
//
|
||||
// That argument is about the BUILD, not about the design. The look is the
|
||||
// house's and is not restated here: the <style> block is @hanzo/brand's own
|
||||
// published token sheet plus this page's layout rules, both compiled in — see
|
||||
// brand.go. Server rendering and one design system are not in tension; only
|
||||
// server rendering and a JS component library are, and it is the tokens, not
|
||||
// the components, that this page ever needed.
|
||||
|
||||
// renderDashboard writes the page for ONE viewer. Every row it renders has
|
||||
// already passed v.visible — the template is never handed the full snapshot and
|
||||
// asked to be careful with it, because a template that can see everything is one
|
||||
// edit away from showing it.
|
||||
func renderDashboard(w http.ResponseWriter, snap snapshot, v viewer, org string, cfg config) {
|
||||
runs := v.visible(snap.Runs, org)
|
||||
if len(runs) > 200 {
|
||||
runs = runs[:200]
|
||||
}
|
||||
|
||||
data := struct {
|
||||
Runs []Run
|
||||
Orgs []string
|
||||
Org string
|
||||
Viewer string
|
||||
Sudo bool
|
||||
Repos int
|
||||
FetchedAt time.Time
|
||||
Age string
|
||||
Stale bool
|
||||
SourceErr string
|
||||
Source string
|
||||
Counts map[string]int
|
||||
}{
|
||||
Runs: runs,
|
||||
Orgs: v.orgs(snap.Runs),
|
||||
Org: org,
|
||||
Viewer: v.org,
|
||||
Sudo: v.sudo,
|
||||
Repos: snap.Repos,
|
||||
FetchedAt: snap.FetchedAt,
|
||||
Age: humanAge(snap.FetchedAt),
|
||||
Stale: snap.stale(cfg.staleAfter),
|
||||
SourceErr: snap.errString(),
|
||||
Source: cfg.gitBase,
|
||||
Counts: countByOutcome(runs),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := tmpl.Execute(w, data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// countByOutcome buckets runs for the summary strip.
|
||||
func countByOutcome(runs []Run) map[string]int {
|
||||
c := map[string]int{"success": 0, "failure": 0, "running": 0, "cancelled": 0}
|
||||
for _, r := range runs {
|
||||
c[outcome(r)]++
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// outcome collapses (status, conclusion) into the four states worth a colour.
|
||||
//
|
||||
// Status alone is NOT enough and getting this wrong is silent: Hanzo Git
|
||||
// reports every finished run as `completed` regardless of how it went, so
|
||||
// bucketing on status painted successes and cancellations as failures — on the
|
||||
// live instance that was 15 of 20 runs mislabelled red.
|
||||
//
|
||||
// `cancelled` gets its own bucket rather than folding into failure. On this
|
||||
// fleet cancellations are the single largest category (superseded pushes cancel
|
||||
// the in-flight run), and a board that shows them as broken is a board nobody
|
||||
// trusts, which is worse than no board.
|
||||
func outcome(r Run) string {
|
||||
if !strings.EqualFold(r.Status, "completed") {
|
||||
return "running" // queued | in_progress | waiting | blocked
|
||||
}
|
||||
switch strings.ToLower(r.Conclusion) {
|
||||
case "success":
|
||||
return "success"
|
||||
case "cancelled", "canceled", "skipped":
|
||||
return "cancelled"
|
||||
case "":
|
||||
// Completed with no conclusion should not happen; if it does, say
|
||||
// "running" rather than inventing a verdict the data does not support.
|
||||
return "running"
|
||||
default:
|
||||
return "failure" // failure | timed_out | action_required
|
||||
}
|
||||
}
|
||||
|
||||
func humanAge(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return "never"
|
||||
}
|
||||
d := time.Since(t)
|
||||
switch {
|
||||
case d < time.Minute:
|
||||
return fmt.Sprintf("%ds ago", int(d.Seconds()))
|
||||
case d < time.Hour:
|
||||
return fmt.Sprintf("%dm ago", int(d.Minutes()))
|
||||
default:
|
||||
return fmt.Sprintf("%dh ago", int(d.Hours()))
|
||||
}
|
||||
}
|
||||
|
||||
func humanDur(d time.Duration) string {
|
||||
if d <= 0 {
|
||||
return "—"
|
||||
}
|
||||
if d < time.Minute {
|
||||
return fmt.Sprintf("%ds", int(d.Seconds()))
|
||||
}
|
||||
return fmt.Sprintf("%dm%02ds", int(d.Minutes()), int(d.Seconds())%60)
|
||||
}
|
||||
|
||||
// `class="dark"` is @hanzo/brand's own dark hook, not a local convention: the
|
||||
// sheet's :root IS the dark scale, and the class is what additionally sets
|
||||
// color-scheme so the scrollbars and form controls the browser draws match.
|
||||
// Elsewhere in the fleet next-themes toggles that class; this page has no JS and
|
||||
// no toggle, so it states its scheme once and means it.
|
||||
var tmpl = template.Must(template.New("ci").Funcs(template.FuncMap{
|
||||
"outcome": outcome,
|
||||
"dur": func(r Run) string { return humanDur(r.Duration()) },
|
||||
"ago": humanAge,
|
||||
"css": pageCSS,
|
||||
}).Parse(`<!doctype html>
|
||||
<html lang="en" class="dark"><head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Hanzo CI</title>
|
||||
<meta http-equiv="refresh" content="60">
|
||||
<style>{{css}}</style></head><body>
|
||||
|
||||
<header>
|
||||
<h1>Hanzo <span>CI</span></h1>
|
||||
<div class="meta">
|
||||
{{.Repos}} repos · refreshed {{.Age}}<br>
|
||||
source {{.Source}}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="strip">
|
||||
<span class="chip ok">passing <b>{{index .Counts "success"}}</b></span>
|
||||
<span class="chip fail">failing <b>{{index .Counts "failure"}}</b></span>
|
||||
<span class="chip run">running <b>{{index .Counts "running"}}</b></span>
|
||||
<span class="chip cancel">cancelled <b>{{index .Counts "cancelled"}}</b></span>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
{{if .Sudo}}<a href="/" {{if eq .Org ""}}class="on"{{end}}>all orgs</a>{{end}}
|
||||
{{range .Orgs}}<a href="/?org={{.}}" {{if eq $.Org .}}class="on"{{end}}>{{.}}</a>{{end}}
|
||||
<span class="who">signed in as {{.Viewer}}{{if .Sudo}} · fleet view{{end}}</span>
|
||||
</nav>
|
||||
|
||||
{{if .Stale}}<div class="warn">
|
||||
Snapshot is stale — last successful refresh {{.Age}}.
|
||||
{{if .SourceErr}}Hanzo Git said: {{.SourceErr}}{{else}}Hanzo Git is not answering.{{end}}
|
||||
These rows are the last good read, not current state.
|
||||
</div>{{end}}
|
||||
|
||||
{{if .Runs}}
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Repository</th><th>Workflow</th><th class="hide-sm">Commit</th>
|
||||
<th class="hide-sm">Actor</th><th>Started</th><th>Took</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{{range .Runs}}
|
||||
<tr>
|
||||
<td><span class="dot {{outcome .}}"></span><a href="{{.URL}}"><span class="org">{{.Org}}/</span><span class="repo">{{.Repo}}</span></a></td>
|
||||
<td>{{.Workflow}} <span class="mono">#{{.Number}}</span><div class="title">{{.Title}}</div></td>
|
||||
<td class="hide-sm mono">{{.Branch}}@{{.SHA}}<div>{{.Event}}</div></td>
|
||||
<td class="hide-sm mono">{{.Actor}}</td>
|
||||
<td class="mono">{{ago .StartedAt}}</td>
|
||||
<td class="mono">{{dur .}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody></table>
|
||||
{{else}}
|
||||
<div class="empty">
|
||||
No runs in the scanned window.<br>
|
||||
<span class="mono">Builds land here from {{.Source}} — this view holds no state of its own.</span>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<footer>
|
||||
Build truth lives in Hanzo Git; this is a view over it.
|
||||
Delivery is <a href="https://cd.hanzo.ai">cd.hanzo.ai</a>.
|
||||
</footer>
|
||||
</body></html>`))
|
||||
@@ -0,0 +1,78 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/http/httptest"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// render_test.go guards the two properties the view has to keep: that its design
|
||||
// values come from exactly one place, and that it can only ever draw rows the
|
||||
// viewer was already permitted to see.
|
||||
|
||||
// TestBrandCSSIsUpstreamBytes is the pin. The vendored sheet is only a source of
|
||||
// truth while it is byte-for-byte what @hanzo/brand published; the moment it can
|
||||
// be edited in place it is a fork wearing an upstream name, which is the exact
|
||||
// state this repo was in when it carried its own :root block.
|
||||
func TestBrandCSSIsUpstreamBytes(t *testing.T) {
|
||||
sum := sha256.Sum256([]byte(brandCSS))
|
||||
got := hex.EncodeToString(sum[:])
|
||||
if got != brandCSSSHA256 {
|
||||
t.Fatalf("brand/variables.css is not @hanzo/brand@%s\n got %s\n want %s\n"+
|
||||
"A token refresh: re-fetch the sheet and set brandCSSSHA256 to the got value.\n"+
|
||||
"A local colour edit: make it in @hanzo/brand and release it, not here.",
|
||||
brandCSSVersion, got, brandCSSSHA256)
|
||||
}
|
||||
}
|
||||
|
||||
// colourLiteral matches a value that decides an appearance on its own — a hex,
|
||||
// or an rgb()/hsl() function. `color-mix(in srgb, var(--x) ...)` is deliberately
|
||||
// not one of these: it derives from a token instead of naming a new colour.
|
||||
var colourLiteral = regexp.MustCompile(`#[0-9a-fA-F]{3,8}\b|\brgba?\(|\bhsla?\(`)
|
||||
|
||||
// TestDashboardCSSNamesNoColours is what makes "one source of truth" a fact
|
||||
// rather than an intention. Vendoring the sheet is only half the job; if the
|
||||
// page can still write a hex next to it, the second palette grows back one
|
||||
// "just this once" at a time — which is how the old :root block came to hold
|
||||
// GitHub's status colours instead of the house's.
|
||||
func TestDashboardCSSNamesNoColours(t *testing.T) {
|
||||
if m := colourLiteral.FindAllString(dashboardCSS, -1); len(m) > 0 {
|
||||
t.Fatalf("dashboard.css names colours directly: %v\n"+
|
||||
"Every colour must be a var() into @hanzo/brand; if the token you need "+
|
||||
"does not exist, add it there rather than here.", m)
|
||||
}
|
||||
if !strings.Contains(dashboardCSS, "var(--") {
|
||||
t.Fatal("dashboard.css references no tokens at all — it has stopped consuming the design system")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderedPageShowsOnlyTheViewersOrg drives the HTML, not the predicates.
|
||||
// scope_test.go proves visible() and orgs() are right; this proves the page is
|
||||
// actually built from them — the leak that started all of this was a handler
|
||||
// handing a template more than the viewer was owed, and a template cannot be
|
||||
// trusted to be careful with a snapshot it can see all of.
|
||||
func TestRenderedPageShowsOnlyTheViewersOrg(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
renderDashboard(w, snapshot{Runs: testRuns(), Repos: 3}, viewer{org: "lux"}, "", config{})
|
||||
body := w.Body.String()
|
||||
|
||||
if !strings.Contains(body, ">lux/<") {
|
||||
t.Fatal("lux viewer's own run is missing from the page")
|
||||
}
|
||||
// Rows: no other org's repo may be drawn.
|
||||
for _, leaked := range []string{">hanzo/<", ">zoo/<"} {
|
||||
if strings.Contains(body, leaked) {
|
||||
t.Errorf("page rendered %s to a lux viewer", leaked)
|
||||
}
|
||||
}
|
||||
// Nav: nor may another org's NAME, which discloses who builds here even
|
||||
// when their runs are correctly hidden.
|
||||
for _, leaked := range []string{"/?org=hanzo", "/?org=zoo", "all orgs"} {
|
||||
if strings.Contains(body, leaked) {
|
||||
t.Errorf("nav offered %q to a lux viewer", leaked)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// scope.go answers exactly one question: whose builds may THIS request see?
|
||||
//
|
||||
// It exists because the first cut of this service conflated a FILTER with a
|
||||
// GATE. `?org=lux` narrowed what was rendered and read like tenancy, but it
|
||||
// decided nothing about who was allowed to ask — so /v1/runs answered 200 to
|
||||
// anyone on the internet with every org's repo names, branches, commit SHAs and
|
||||
// actor logins. A query parameter is a request for a view; it can never be the
|
||||
// authority for one.
|
||||
//
|
||||
// The authority is X-Org-Id, minted by admin-guard from the IAM-verified `owner`
|
||||
// claim and written onto the request by the ingress middleware's
|
||||
// authResponseHeaders. Traefik OVERWRITES any client-sent X-Org-Id with the
|
||||
// guard's value, so on the wired path the header cannot be forged. This file
|
||||
// still treats its ABSENCE as fatal rather than as "no filter", because absence
|
||||
// is the signal that the request did not come through the guard at all.
|
||||
|
||||
// orgHeader is the identity the whole surface is scoped by. One name, one
|
||||
// meaning, platform-wide (see the X-* header convention: X-Org-Id is the org
|
||||
// slug from the JWT `owner` claim).
|
||||
const orgHeader = "X-Org-Id"
|
||||
|
||||
// viewer is the resolved, trusted answer. Constructed only from headers the
|
||||
// guard controls — never from the query string, never from a cookie.
|
||||
type viewer struct {
|
||||
// org is the caller's home org slug, from the verified `owner` claim.
|
||||
org string
|
||||
// sudo reports whether org is the platform admin org, which is the ONE
|
||||
// identity that may see across tenants (the fleet view).
|
||||
sudo bool
|
||||
}
|
||||
|
||||
// resolveViewer lifts the guard-set header into a viewer. It fails closed: a
|
||||
// missing or blank X-Org-Id yields ok=false and the caller MUST refuse the
|
||||
// request.
|
||||
//
|
||||
// Defaulting an absent header to "no filter" is the specific bug this function
|
||||
// exists to prevent — that default is what turns "reached ci without the guard"
|
||||
// into "rendered every org's builds".
|
||||
func resolveViewer(r *http.Request, adminOrg string) (viewer, bool) {
|
||||
org := strings.TrimSpace(r.Header.Get(orgHeader))
|
||||
if org == "" {
|
||||
return viewer{}, false
|
||||
}
|
||||
return viewer{org: org, sudo: strings.EqualFold(org, strings.TrimSpace(adminOrg))}, true
|
||||
}
|
||||
|
||||
// visible narrows runs to what v is permitted to see, then applies want (the
|
||||
// optional `?org=` selection) WITHIN that permission.
|
||||
//
|
||||
// The ordering is the whole point: permission is applied first and `want` can
|
||||
// only ever narrow the result. A lux viewer asking for `?org=hanzo` gets an
|
||||
// empty list, not hanzo's builds — the parameter selects among what you may
|
||||
// already see, it never reaches for more.
|
||||
func (v viewer) visible(runs []Run, want string) []Run {
|
||||
want = strings.TrimSpace(want)
|
||||
if v.sudo {
|
||||
// The fleet view: every org, narrowed by the requested one if given.
|
||||
return filterByOrg(runs, want)
|
||||
}
|
||||
if want != "" && !strings.EqualFold(want, v.org) {
|
||||
return nil
|
||||
}
|
||||
return filterByOrg(runs, v.org)
|
||||
}
|
||||
|
||||
// orgs lists the org tabs this viewer may choose between. A tenant gets exactly
|
||||
// its own org — rendering the full org list to a tenant would leak the set of
|
||||
// orgs that build on the platform even though their runs are correctly hidden.
|
||||
func (v viewer) orgs(runs []Run) []string {
|
||||
if v.sudo {
|
||||
return orgsOf(runs)
|
||||
}
|
||||
return []string{v.org}
|
||||
}
|
||||
|
||||
// requireViewer resolves the viewer or writes the refusal. It returns ok=false
|
||||
// when the request must not proceed.
|
||||
func requireViewer(w http.ResponseWriter, r *http.Request, adminOrg string) (viewer, bool) {
|
||||
v, ok := resolveViewer(r, adminOrg)
|
||||
if ok {
|
||||
return v, true
|
||||
}
|
||||
// 403, not 401: a 401 invites a credential retry, but there is nothing the
|
||||
// CALLER can add to fix this. The header is set by infrastructure, so its
|
||||
// absence is a routing fault (ci reached off-guard) and the honest answer is
|
||||
// that this path is not authorized to serve, whoever is asking.
|
||||
http.Error(w, "forbidden: no "+orgHeader+" (this service is only reachable through the IAM gate)", http.StatusForbidden)
|
||||
return viewer{}, false
|
||||
}
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// scope_test.go is the regression suite for the leak this service shipped with:
|
||||
// /v1/runs answered 200 to anyone, with every org's repo names, branches, commit
|
||||
// SHAs and actor logins, because `?org=` was a filter being used as a gate.
|
||||
//
|
||||
// The properties asserted here are the ones that made it a leak, not merely the
|
||||
// ones that make the new code work.
|
||||
|
||||
func testRuns() []Run {
|
||||
return []Run{
|
||||
{Org: "hanzo", Repo: "cloud", Workflow: "build", Status: "completed", Conclusion: "success"},
|
||||
{Org: "lux", Repo: "node", Workflow: "build", Status: "completed", Conclusion: "failure"},
|
||||
{Org: "zoo", Repo: "app", Workflow: "test", Status: "in_progress"},
|
||||
}
|
||||
}
|
||||
|
||||
// TestNoOrgHeaderIsRefused is the core fix. An absent X-Org-Id means the request
|
||||
// did not come through the IAM gate; the ONLY safe answer is to refuse. The old
|
||||
// code treated the equivalent condition (no `?org=`) as "show everything".
|
||||
func TestNoOrgHeaderIsRefused(t *testing.T) {
|
||||
for _, hdr := range []string{"", " "} {
|
||||
r := httptest.NewRequest(http.MethodGet, "/v1/runs", nil)
|
||||
if hdr != "" {
|
||||
r.Header.Set(orgHeader, hdr)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
v, ok := requireViewer(w, r, "admin")
|
||||
if ok {
|
||||
t.Fatalf("X-Org-Id=%q admitted as viewer %+v — absence must fail closed", hdr, v)
|
||||
}
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("X-Org-Id=%q: status=%d want 403", hdr, w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTenantCannotWidenWithQueryParam is the attack the original design invited:
|
||||
// the caller picks the org. Now the header decides and the parameter may only
|
||||
// narrow, so a lux viewer asking for hanzo's builds gets nothing — NOT hanzo's
|
||||
// builds, and not a silent fallback to its own either (that would be confusing,
|
||||
// but it is the empty answer that matters for security).
|
||||
func TestTenantCannotWidenWithQueryParam(t *testing.T) {
|
||||
lux := viewer{org: "lux"}
|
||||
|
||||
got := lux.visible(testRuns(), "hanzo")
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("lux viewer asking ?org=hanzo saw %d runs (%+v) — must see none", len(got), got)
|
||||
}
|
||||
|
||||
own := lux.visible(testRuns(), "")
|
||||
if len(own) != 1 || own[0].Org != "lux" {
|
||||
t.Fatalf("lux viewer saw %+v; want exactly its own org", own)
|
||||
}
|
||||
if same := lux.visible(testRuns(), "lux"); len(same) != 1 {
|
||||
t.Errorf("lux viewer asking ?org=lux saw %d runs; want its own 1", len(same))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSudoSeesFleetAndCanNarrow asserts the admin org keeps the cross-tenant
|
||||
// view that makes this dashboard useful to the platform, and that `?org=` still
|
||||
// works as a plain filter for it.
|
||||
func TestSudoSeesFleetAndCanNarrow(t *testing.T) {
|
||||
sudo := viewer{org: "admin", sudo: true}
|
||||
|
||||
if all := sudo.visible(testRuns(), ""); len(all) != 3 {
|
||||
t.Fatalf("sudo saw %d runs; want all 3", len(all))
|
||||
}
|
||||
one := sudo.visible(testRuns(), "zoo")
|
||||
if len(one) != 1 || one[0].Org != "zoo" {
|
||||
t.Fatalf("sudo ?org=zoo saw %+v; want zoo only", one)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveViewerSudoDetection pins the sudo bit to the configured admin org,
|
||||
// case-insensitively, and proves an ordinary org never gets it.
|
||||
func TestResolveViewerSudoDetection(t *testing.T) {
|
||||
cases := []struct {
|
||||
hdr, adminOrg string
|
||||
wantSudo bool
|
||||
}{
|
||||
{"admin", "admin", true},
|
||||
{"ADMIN", "admin", true},
|
||||
{" admin ", "admin", true},
|
||||
{"lux", "admin", false},
|
||||
{"administrator", "admin", false}, // prefix must not match
|
||||
{"admin", "root", false}, // honours a non-default admin org
|
||||
}
|
||||
for _, tc := range cases {
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.Header.Set(orgHeader, tc.hdr)
|
||||
v, ok := resolveViewer(r, tc.adminOrg)
|
||||
if !ok {
|
||||
t.Fatalf("X-Org-Id=%q: not resolved", tc.hdr)
|
||||
}
|
||||
if v.sudo != tc.wantSudo {
|
||||
t.Errorf("X-Org-Id=%q adminOrg=%q: sudo=%v want %v", tc.hdr, tc.adminOrg, v.sudo, tc.wantSudo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTenantOrgListIsNotTheFleetList covers the quieter leak: even with runs
|
||||
// correctly hidden, rendering every org's NAME in the nav would disclose the set
|
||||
// of orgs that build on the platform.
|
||||
func TestTenantOrgListIsNotTheFleetList(t *testing.T) {
|
||||
lux := viewer{org: "lux"}
|
||||
orgs := lux.orgs(testRuns())
|
||||
if len(orgs) != 1 || orgs[0] != "lux" {
|
||||
t.Fatalf("tenant org list = %v; want only its own org", orgs)
|
||||
}
|
||||
if sudoOrgs := (viewer{org: "admin", sudo: true}).orgs(testRuns()); len(sudoOrgs) != 3 {
|
||||
t.Errorf("sudo org list = %v; want all 3", sudoOrgs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunsEndpointScopesEndToEnd drives the actual HTTP handler wiring, not just
|
||||
// the predicates — the leak was in the handler, so the handler is what must be
|
||||
// asserted.
|
||||
func TestRunsEndpointScopesEndToEnd(t *testing.T) {
|
||||
cache := &runCache{}
|
||||
cache.put(snapshot{Runs: testRuns(), Repos: 3})
|
||||
cfg := config{adminOrg: "admin"}
|
||||
|
||||
h := func(w http.ResponseWriter, r *http.Request) {
|
||||
v, ok := requireViewer(w, r, cfg.adminOrg)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
snap := cache.get()
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"runs": v.visible(snap.Runs, r.URL.Query().Get("org")),
|
||||
"orgs": v.orgs(snap.Runs),
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("anonymous → 403", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
h(w, httptest.NewRequest(http.MethodGet, "/v1/runs", nil))
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("status=%d want 403; body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if strings.Contains(w.Body.String(), "cloud") || strings.Contains(w.Body.String(), "node") {
|
||||
t.Error("refusal body leaked repo names")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("lux viewer sees only lux, even asking for hanzo", func(t *testing.T) {
|
||||
r := httptest.NewRequest(http.MethodGet, "/v1/runs?org=hanzo", nil)
|
||||
r.Header.Set(orgHeader, "lux")
|
||||
w := httptest.NewRecorder()
|
||||
h(w, r)
|
||||
|
||||
var got struct {
|
||||
Runs []Run `json:"runs"`
|
||||
Orgs []string `json:"orgs"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(got.Runs) != 0 {
|
||||
t.Errorf("lux asking ?org=hanzo got %+v; want none", got.Runs)
|
||||
}
|
||||
if len(got.Orgs) != 1 || got.Orgs[0] != "lux" {
|
||||
t.Errorf("orgs=%v; want [lux]", got.Orgs)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user