Compare commits
3 Commits
e875c3ef22
...
88f3e1d019
| Author | SHA1 | Date | |
|---|---|---|---|
| 88f3e1d019 | |||
| c378690977 | |||
| 279effb4c2 |
@@ -3,6 +3,11 @@
|
|||||||
# stage of the Dockerfile.
|
# stage of the Dockerfile.
|
||||||
.git/
|
.git/
|
||||||
bin/
|
bin/
|
||||||
|
# Third-party browser assets are fetched and hash-verified inside the build by
|
||||||
|
# script/fetch-assets. Excluding any host copy keeps a developer's working tree
|
||||||
|
# from supplying the bytes that get shipped. The script and its
|
||||||
|
# static/vendor.sha256 manifest stay in the context.
|
||||||
|
static/js/alpine.min.js
|
||||||
*.md
|
*.md
|
||||||
LICENSE
|
LICENSE
|
||||||
.editorconfig
|
.editorconfig
|
||||||
|
|||||||
@@ -16,36 +16,15 @@ jobs:
|
|||||||
# that touched the Docker build context.
|
# that touched the Docker build context.
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Neutralize superseded run statuses
|
- name: Mark superseded run statuses
|
||||||
# Gitea cancels the in-flight run when another commit is pushed to the
|
# Gitea cancels the in-flight run when another commit is pushed to the
|
||||||
# same branch and records the cancellation as `failure`, so a commit
|
# same branch and records the cancellation as `failure`, so a commit
|
||||||
# that was never tested reads red. The cancellation is unconditional
|
# that was never tested reads as a test result. The script rewrites
|
||||||
# server-side for push events and cannot be disabled from a workflow
|
# those statuses to say what happened. See its header for why the
|
||||||
# file, so the superseding run rewrites those statuses to `skipped`.
|
# state stays `failure` and not `skipped`.
|
||||||
# Only the exact cancellation status is touched; a real failure is
|
|
||||||
# left alone.
|
|
||||||
env:
|
env:
|
||||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
run: |
|
run: script/ci-mark-superseded
|
||||||
set -eu
|
|
||||||
api="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}"
|
|
||||||
ctx='check / check (push)'
|
|
||||||
for sha in $(git rev-list --max-count=20 "${GITHUB_SHA}^" || true); do
|
|
||||||
latest="$(curl -sf "${api}/commits/${sha}/status" | jq -r \
|
|
||||||
--arg c "$ctx" \
|
|
||||||
'[.statuses[] | select(.context == $c)][0] // empty
|
|
||||||
| "\(.status)|\(.description)"')" || continue
|
|
||||||
[ "$latest" = 'failure|Has been cancelled' ] || continue
|
|
||||||
curl -sf -X POST "${api}/statuses/${sha}" \
|
|
||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d "$(jq -nc --arg c "$ctx" '{
|
|
||||||
context: $c,
|
|
||||||
state: "skipped",
|
|
||||||
description: "Superseded by a newer commit; never tested"
|
|
||||||
}')" >/dev/null
|
|
||||||
echo "neutralized superseded status on ${sha}"
|
|
||||||
done
|
|
||||||
|
|
||||||
- name: Fingerprint the build context
|
- name: Fingerprint the build context
|
||||||
# `.dockerignore` keeps docs out of the build context, so a docs-only
|
# `.dockerignore` keeps docs out of the build context, so a docs-only
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -45,3 +45,8 @@ temp/
|
|||||||
|
|
||||||
# CI cache barrier, written into the build context by the check workflow
|
# CI cache barrier, written into the build context by the check workflow
|
||||||
.ci-fingerprint
|
.ci-fingerprint
|
||||||
|
|
||||||
|
# Third-party browser assets, fetched and hash-verified by
|
||||||
|
# script/fetch-assets against static/vendor.sha256. Not committed:
|
||||||
|
# REPO_POLICIES.md forbids minified bundles in version control.
|
||||||
|
/static/js/alpine.min.js
|
||||||
12
Dockerfile
12
Dockerfile
@@ -32,7 +32,9 @@ FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a349228
|
|||||||
# Depend on lint stage passing
|
# Depend on lint stage passing
|
||||||
COPY --from=lint /src/go.sum /dev/null
|
COPY --from=lint /src/go.sum /dev/null
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends make && rm -rf /var/lib/apt/lists/*
|
# jq is a runtime dependency of script/ci-mark-superseded, which the test
|
||||||
|
# suite executes.
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends make curl ca-certificates jq && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
|
|
||||||
@@ -44,6 +46,14 @@ RUN go mod download
|
|||||||
# the lint stage above.
|
# the lint stage above.
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# Fetch the third-party browser assets the UI serves. They are not committed
|
||||||
|
# (REPO_POLICIES.md forbids minified bundles in version control) and
|
||||||
|
# .dockerignore keeps any host copy out of the build context, so this step is
|
||||||
|
# the only way they enter the image. Each download is checked against a
|
||||||
|
# hardcoded sha256 and the build fails on mismatch; make test re-checks the
|
||||||
|
# hashes against the bytes go:embed actually put in the binary.
|
||||||
|
RUN script/fetch-assets
|
||||||
|
|
||||||
# Run tests and build
|
# Run tests and build
|
||||||
RUN make test
|
RUN make test
|
||||||
RUN make build
|
RUN make build
|
||||||
|
|||||||
5
Makefile
5
Makefile
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: bootstrap setup test lint fmt fmt-check check build run dev deps docker clean hooks css
|
.PHONY: bootstrap setup assets test lint fmt fmt-check check build run dev deps docker clean hooks css
|
||||||
|
|
||||||
# Default target
|
# Default target
|
||||||
.DEFAULT_GOAL := check
|
.DEFAULT_GOAL := check
|
||||||
@@ -9,6 +9,9 @@ bootstrap:
|
|||||||
setup:
|
setup:
|
||||||
@script/setup
|
@script/setup
|
||||||
|
|
||||||
|
assets:
|
||||||
|
@script/fetch-assets
|
||||||
|
|
||||||
test:
|
test:
|
||||||
@script/test
|
@script/test
|
||||||
|
|
||||||
|
|||||||
54
README.md
54
README.md
@@ -40,6 +40,7 @@ make docker
|
|||||||
```bash
|
```bash
|
||||||
make bootstrap # Install all dependencies (idempotent)
|
make bootstrap # Install all dependencies (idempotent)
|
||||||
make setup # Bootstrap + install git pre-commit hook
|
make setup # Bootstrap + install git pre-commit hook
|
||||||
|
make assets # Fetch + verify third-party browser assets
|
||||||
make fmt # Format code (gofmt + goimports)
|
make fmt # Format code (gofmt + goimports)
|
||||||
make lint # Run golangci-lint
|
make lint # Run golangci-lint
|
||||||
make test # Run tests with race detection
|
make test # Run tests with race detection
|
||||||
@@ -247,6 +248,8 @@ them. We provide:
|
|||||||
- `script/setup` — make a fresh clone ready for development
|
- `script/setup` — make a fresh clone ready for development
|
||||||
(bootstrap, then install-precommit)
|
(bootstrap, then install-precommit)
|
||||||
- `script/projectname` — output the project name ("webhooker")
|
- `script/projectname` — output the project name ("webhooker")
|
||||||
|
- `script/fetch-assets` — download the third-party browser assets into
|
||||||
|
`static/`, verifying each against its pinned sha256
|
||||||
- `script/test` — run the test suite
|
- `script/test` — run the test suite
|
||||||
- `script/lint` — run golangci-lint
|
- `script/lint` — run golangci-lint
|
||||||
- `script/fmt` — format all code (writes)
|
- `script/fmt` — format all code (writes)
|
||||||
@@ -255,11 +258,34 @@ them. We provide:
|
|||||||
- `script/docker` — build the Docker image tagged via `script/projectname`
|
- `script/docker` — build the Docker image tagged via `script/projectname`
|
||||||
- `script/cibuild` — CI entrypoint: `docker build .` (the Dockerfile
|
- `script/cibuild` — CI entrypoint: `docker build .` (the Dockerfile
|
||||||
runs the checks, so a green build implies a green repo)
|
runs the checks, so a green build implies a green repo)
|
||||||
|
- `script/ci-mark-superseded` — CI helper: mark the commits whose run a
|
||||||
|
newer push cancelled (see [CI gate honesty](#ci-gate-honesty))
|
||||||
- `script/precommit` — pre-commit checks (`go mod tidy` guard, then
|
- `script/precommit` — pre-commit checks (`go mod tidy` guard, then
|
||||||
`script/check`)
|
`script/check`)
|
||||||
- `script/install-precommit` — install the git pre-commit hook that
|
- `script/install-precommit` — install the git pre-commit hook that
|
||||||
runs `script/precommit`
|
runs `script/precommit`
|
||||||
|
|
||||||
|
## Third-party browser assets
|
||||||
|
|
||||||
|
The web UI serves one third-party script, Alpine.js. It is **not** committed:
|
||||||
|
a minified bundle in the tree is unreviewable, and `REPO_POLICIES.md` bars
|
||||||
|
both committed build artifacts and unpinned external references.
|
||||||
|
|
||||||
|
Instead `script/fetch-assets` downloads it from a pinned URL, checks the
|
||||||
|
download against a hardcoded sha256, and installs it under `static/`. The
|
||||||
|
sha256 of every installed asset is recorded in `static/vendor.sha256`, and
|
||||||
|
`static/vendor_test.go` re-hashes the bytes `go:embed` put in the binary
|
||||||
|
against that manifest — so the pin is enforced on what actually ships, not
|
||||||
|
merely written down. Any mismatch fails the build.
|
||||||
|
|
||||||
|
`make bootstrap` runs the fetch for local development, and the Dockerfile
|
||||||
|
runs it in the build stage; `.gitignore` and `.dockerignore` keep the
|
||||||
|
artifact out of both the repo and the build context.
|
||||||
|
|
||||||
|
To move to a new version: update the version, URL, and tarball sha256 in
|
||||||
|
`script/fetch-assets` and the asset sha256 in `static/vendor.sha256`, then
|
||||||
|
run `make assets && make check`.
|
||||||
|
|
||||||
## Rationale
|
## Rationale
|
||||||
|
|
||||||
Webhook integrations between services are inherently fragile. The
|
Webhook integrations between services are inherently fragile. The
|
||||||
@@ -1205,11 +1231,29 @@ way.
|
|||||||
|
|
||||||
The workflow's first step covers a second way the gate lied: Gitea
|
The workflow's first step covers a second way the gate lied: Gitea
|
||||||
cancels an in-flight run when a newer commit lands on the same branch
|
cancels an in-flight run when a newer commit lands on the same branch
|
||||||
and records that cancellation as a `failure` status, marking a commit
|
and records that cancellation as a `failure` status, so a commit nothing
|
||||||
red that was never tested. Cancellation is unconditional server-side for
|
ever tested reads as a test result. Cancellation is unconditional
|
||||||
push events, so the superseding run rewrites the exact
|
server-side for push events, so the superseding run calls
|
||||||
`Has been cancelled` status to `skipped`. Genuine failures are never
|
`script/ci-mark-superseded`, which rewrites that exact status to
|
||||||
touched.
|
`failure` / `Superseded by a newer commit; never tested`.
|
||||||
|
|
||||||
|
The state stays `failure` on purpose: Gitea's combined status folds
|
||||||
|
`skipped` into `success`, so marking a never-tested commit `skipped`
|
||||||
|
made the status API report green for it, indistinguishable from a commit
|
||||||
|
that passed. Reading a commit's status on this repo therefore goes:
|
||||||
|
|
||||||
|
- `success` / `Successful in ...` — the checks ran and passed.
|
||||||
|
- `failure` / `Failing after ...` — the checks ran and failed.
|
||||||
|
- `failure` / `Superseded by a newer commit; never tested` — the run was
|
||||||
|
cancelled by a newer push, and nothing was verified about this commit.
|
||||||
|
Test the commit itself before concluding anything about it.
|
||||||
|
|
||||||
|
Genuine failures and successes are never touched, and no status is left
|
||||||
|
`pending`, which would block the commit indefinitely. The step derives
|
||||||
|
its context string from the workflow name, job name and event — the same
|
||||||
|
three values Gitea builds the context from — and fails loudly when no
|
||||||
|
status carries that context, so renaming the workflow or the job cannot
|
||||||
|
silently disable the rewrite.
|
||||||
|
|
||||||
## TODO
|
## TODO
|
||||||
|
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -17,6 +17,7 @@ require (
|
|||||||
github.com/stretchr/testify v1.8.4
|
github.com/stretchr/testify v1.8.4
|
||||||
go.uber.org/fx v1.20.1
|
go.uber.org/fx v1.20.1
|
||||||
golang.org/x/crypto v0.38.0
|
golang.org/x/crypto v0.38.0
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
gorm.io/driver/sqlite v1.5.4
|
gorm.io/driver/sqlite v1.5.4
|
||||||
gorm.io/gorm v1.25.5
|
gorm.io/gorm v1.25.5
|
||||||
modernc.org/sqlite v1.28.0
|
modernc.org/sqlite v1.28.0
|
||||||
@@ -52,7 +53,6 @@ require (
|
|||||||
golang.org/x/text v0.25.0 // indirect
|
golang.org/x/text v0.25.0 // indirect
|
||||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
|
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
|
||||||
google.golang.org/protobuf v1.31.0 // indirect
|
google.golang.org/protobuf v1.31.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
|
||||||
lukechampine.com/uint128 v1.2.0 // indirect
|
lukechampine.com/uint128 v1.2.0 // indirect
|
||||||
modernc.org/cc/v3 v3.40.0 // indirect
|
modernc.org/cc/v3 v3.40.0 // indirect
|
||||||
modernc.org/ccgo/v3 v3.16.13 // indirect
|
modernc.org/ccgo/v3 v3.16.13 // indirect
|
||||||
|
|||||||
349
internal/ciscript/ci_mark_superseded_test.go
Normal file
349
internal/ciscript/ci_mark_superseded_test.go
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
package ciscript_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"maps"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// supersededDesc is the description script/ci-mark-superseded
|
||||||
|
// writes, and the one an earlier revision of it wrote alongside a
|
||||||
|
// `skipped` state.
|
||||||
|
supersededDesc = "Superseded by a newer commit; never tested"
|
||||||
|
|
||||||
|
// liveContext is the commit-status context Gitea uses for this
|
||||||
|
// repository's runs, as seen in its API. The script derives it from
|
||||||
|
// the workflow and job names rather than hardcoding it; the
|
||||||
|
// derivation is checked against this value below.
|
||||||
|
liveContext = "check / check (push)"
|
||||||
|
|
||||||
|
scriptPath = "../../script/ci-mark-superseded"
|
||||||
|
workflow = "../../.gitea/workflows/check.yml"
|
||||||
|
|
||||||
|
// failure is the only state that neither folds into a combined
|
||||||
|
// `success` (as `skipped` does) nor blocks the commit forever (as
|
||||||
|
// `pending` does).
|
||||||
|
failure = "failure"
|
||||||
|
)
|
||||||
|
|
||||||
|
// repo is a throwaway git history: parent is the commit a run would be
|
||||||
|
// cancelled on, head the commit that superseded it.
|
||||||
|
type repo struct {
|
||||||
|
dir string
|
||||||
|
head string
|
||||||
|
parent string
|
||||||
|
}
|
||||||
|
|
||||||
|
// scriptEnv is the run identity the Gitea runner exports and the script
|
||||||
|
// builds its context string from.
|
||||||
|
type scriptEnv struct {
|
||||||
|
workflow string
|
||||||
|
job string
|
||||||
|
event string
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultEnv() scriptEnv {
|
||||||
|
return scriptEnv{workflow: "check", job: "check", event: "push"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelled() commitStatus {
|
||||||
|
return commitStatus{
|
||||||
|
Context: liveContext,
|
||||||
|
Status: failure,
|
||||||
|
Description: "Has been cancelled",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func running() commitStatus {
|
||||||
|
return commitStatus{
|
||||||
|
Context: liveContext,
|
||||||
|
Status: "pending",
|
||||||
|
Description: "Has started running",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarkSuperseded(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cases := map[string]struct {
|
||||||
|
parent commitStatus
|
||||||
|
wantMark bool
|
||||||
|
}{
|
||||||
|
"a cancelled run is marked": {
|
||||||
|
parent: cancelled(),
|
||||||
|
wantMark: true,
|
||||||
|
},
|
||||||
|
"a laundered skipped status is marked": {
|
||||||
|
parent: commitStatus{
|
||||||
|
Context: liveContext,
|
||||||
|
Status: "skipped",
|
||||||
|
Description: supersededDesc,
|
||||||
|
},
|
||||||
|
wantMark: true,
|
||||||
|
},
|
||||||
|
"a genuine failure is left alone": {
|
||||||
|
parent: commitStatus{
|
||||||
|
Context: liveContext,
|
||||||
|
Status: failure,
|
||||||
|
Description: "Failing after 3m1s",
|
||||||
|
},
|
||||||
|
wantMark: false,
|
||||||
|
},
|
||||||
|
"a passing run is left alone": {
|
||||||
|
parent: commitStatus{
|
||||||
|
Context: liveContext,
|
||||||
|
Status: "success",
|
||||||
|
Description: "Successful in 2m52s",
|
||||||
|
},
|
||||||
|
wantMark: false,
|
||||||
|
},
|
||||||
|
"another context is left alone": {
|
||||||
|
parent: commitStatus{
|
||||||
|
Context: "other / other (push)",
|
||||||
|
Status: failure,
|
||||||
|
Description: "Has been cancelled",
|
||||||
|
},
|
||||||
|
wantMark: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, tc := range cases {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
requireTools(t)
|
||||||
|
|
||||||
|
history := newRepo(t)
|
||||||
|
fake, api := newFakeGitea(t)
|
||||||
|
fake.setStatus(history.head, running())
|
||||||
|
fake.setStatus(history.parent, tc.parent)
|
||||||
|
|
||||||
|
out, err := runScript(t, history, api, defaultEnv())
|
||||||
|
require.NoError(t, err, out)
|
||||||
|
|
||||||
|
posted := fake.postedFor(history.parent)
|
||||||
|
if !tc.wantMark {
|
||||||
|
require.Empty(t, posted)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.Equal(t, []postedStatus{{
|
||||||
|
Context: liveContext,
|
||||||
|
// Not `skipped`: Gitea's combined status folds
|
||||||
|
// that into `success`, which is what made a
|
||||||
|
// never-tested commit read green.
|
||||||
|
State: failure,
|
||||||
|
Description: supersededDesc,
|
||||||
|
}}, posted)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A second run must not rewrite what the first one wrote, or every
|
||||||
|
// later push would post a duplicate status.
|
||||||
|
func TestMarkSupersededIsIdempotent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
requireTools(t)
|
||||||
|
|
||||||
|
history := newRepo(t)
|
||||||
|
fake, api := newFakeGitea(t)
|
||||||
|
fake.setStatus(history.head, running())
|
||||||
|
fake.setStatus(history.parent, cancelled())
|
||||||
|
|
||||||
|
for range 2 {
|
||||||
|
out, err := runScript(t, history, api, defaultEnv())
|
||||||
|
require.NoError(t, err, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
require.Len(t, fake.postedFor(history.parent), 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renaming the workflow or the job changes the context string Gitea
|
||||||
|
// uses. The script must say so instead of quietly matching nothing.
|
||||||
|
func TestMarkSupersededRejectsAnUnknownContext(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
requireTools(t)
|
||||||
|
|
||||||
|
history := newRepo(t)
|
||||||
|
fake, api := newFakeGitea(t)
|
||||||
|
fake.setStatus(history.head, running())
|
||||||
|
fake.setStatus(history.parent, cancelled())
|
||||||
|
|
||||||
|
env := defaultEnv()
|
||||||
|
env.job = "renamed"
|
||||||
|
|
||||||
|
out, err := runScript(t, history, api, env)
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, out, "renamed")
|
||||||
|
require.Contains(t, out, liveContext)
|
||||||
|
require.Empty(t, fake.postedFor(history.parent))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ANCESTOR_LIMIT is a documented knob. A value that is set but unusable
|
||||||
|
// must abort: handing it to git and discarding the exit status left the
|
||||||
|
// walk empty and the step green, marking nothing.
|
||||||
|
func TestMarkSupersededRejectsAnUnparseableAncestorLimit(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
requireTools(t)
|
||||||
|
|
||||||
|
history := newRepo(t)
|
||||||
|
fake, api := newFakeGitea(t)
|
||||||
|
fake.setStatus(history.head, running())
|
||||||
|
fake.setStatus(history.parent, cancelled())
|
||||||
|
|
||||||
|
out, err := runScript(
|
||||||
|
t, history, api, defaultEnv(), "ANCESTOR_LIMIT=twenty",
|
||||||
|
)
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, out, "ANCESTOR_LIMIT")
|
||||||
|
require.Contains(t, out, "twenty")
|
||||||
|
require.Empty(t, fake.postedFor(history.parent))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A status read that fails is not the same as a commit with nothing to
|
||||||
|
// do. Losing curl's exit status through a pipe made the two identical
|
||||||
|
// and left a laundered commit laundered with no signal.
|
||||||
|
func TestMarkSupersededFailsOnAnUnreadableAncestorStatus(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
requireTools(t)
|
||||||
|
|
||||||
|
history := newRepo(t)
|
||||||
|
fake, api := newFakeGitea(t)
|
||||||
|
fake.setStatus(history.head, running())
|
||||||
|
fake.setStatus(history.parent, cancelled())
|
||||||
|
fake.failStatusRead(history.parent)
|
||||||
|
|
||||||
|
out, err := runScript(t, history, api, defaultEnv())
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, out, history.parent)
|
||||||
|
require.Contains(t, out, "cannot read commit statuses")
|
||||||
|
require.Empty(t, fake.postedFor(history.parent))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The derived context must equal the one Gitea actually uses, which is
|
||||||
|
// built from the same workflow and job names.
|
||||||
|
func TestDerivedContextMatchesGitea(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
requireTools(t)
|
||||||
|
|
||||||
|
name, job := workflowIdentity(t)
|
||||||
|
|
||||||
|
history := newRepo(t)
|
||||||
|
fake, api := newFakeGitea(t)
|
||||||
|
fake.setStatus(history.head, running())
|
||||||
|
fake.setStatus(history.parent, cancelled())
|
||||||
|
|
||||||
|
out, err := runScript(t, history, api, scriptEnv{
|
||||||
|
workflow: name,
|
||||||
|
job: job,
|
||||||
|
event: "push",
|
||||||
|
})
|
||||||
|
require.NoError(t, err, out)
|
||||||
|
|
||||||
|
posted := fake.postedFor(history.parent)
|
||||||
|
require.Len(t, posted, 1)
|
||||||
|
require.Equal(t, liveContext, posted[0].Context)
|
||||||
|
}
|
||||||
|
|
||||||
|
// workflowIdentity reads the workflow name and its single job id out of
|
||||||
|
// the checked-in workflow file.
|
||||||
|
func workflowIdentity(t *testing.T) (string, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
raw, err := os.ReadFile(workflow)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var parsed struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Jobs map[string]any `yaml:"jobs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, yaml.Unmarshal(raw, &parsed))
|
||||||
|
|
||||||
|
jobs := slices.Collect(maps.Keys(parsed.Jobs))
|
||||||
|
require.Len(t, jobs, 1)
|
||||||
|
|
||||||
|
return parsed.Name, jobs[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func runScript(
|
||||||
|
t *testing.T, history repo, api string, env scriptEnv,
|
||||||
|
extra ...string,
|
||||||
|
) (string, error) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
script, err := filepath.Abs(scriptPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
//nolint:gosec // fixed argv, repo-local script under test
|
||||||
|
cmd := exec.CommandContext(t.Context(), "sh", script)
|
||||||
|
cmd.Dir = history.dir
|
||||||
|
cmd.Env = append(os.Environ(),
|
||||||
|
"GITHUB_API_URL="+api,
|
||||||
|
"GITHUB_REPOSITORY=sneak/webhooker",
|
||||||
|
"GITHUB_SHA="+history.head,
|
||||||
|
"GITHUB_WORKFLOW="+env.workflow,
|
||||||
|
"GITHUB_JOB="+env.job,
|
||||||
|
"GITHUB_EVENT_NAME="+env.event,
|
||||||
|
"GITEA_TOKEN=test-token",
|
||||||
|
)
|
||||||
|
cmd.Env = append(cmd.Env, extra...)
|
||||||
|
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
|
||||||
|
return string(out), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRepo(t *testing.T) repo {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
git := func(args ...string) string {
|
||||||
|
//nolint:gosec // fixed argv, arguments are test constants
|
||||||
|
cmd := exec.CommandContext(t.Context(), "git", args...)
|
||||||
|
cmd.Dir = dir
|
||||||
|
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
require.NoError(t, err, string(out))
|
||||||
|
|
||||||
|
return strings.TrimSpace(string(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
commit := func(message string) string {
|
||||||
|
git(
|
||||||
|
"-c", "user.email=ci@example.invalid",
|
||||||
|
"-c", "user.name=ci",
|
||||||
|
"-c", "commit.gpgsign=false",
|
||||||
|
"commit", "-q", "--allow-empty", "-m", message,
|
||||||
|
)
|
||||||
|
|
||||||
|
return git("rev-parse", "HEAD")
|
||||||
|
}
|
||||||
|
|
||||||
|
git("init", "-q", "-b", "main")
|
||||||
|
|
||||||
|
parent := commit("parent")
|
||||||
|
head := commit("head")
|
||||||
|
|
||||||
|
return repo{dir: dir, head: head, parent: parent}
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireTools(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
for _, tool := range []string{"sh", "git", "curl", "jq"} {
|
||||||
|
_, err := exec.LookPath(tool)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("%s is not installed: %v", tool, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
5
internal/ciscript/doc.go
Normal file
5
internal/ciscript/doc.go
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// Package ciscript holds the tests for the repository's CI shell
|
||||||
|
// scripts in script/. It carries no runtime code: the scripts run on
|
||||||
|
// the CI runner, not inside the binary, but their behaviour still has
|
||||||
|
// to be verified by the test suite.
|
||||||
|
package ciscript
|
||||||
162
internal/ciscript/fakegitea_test.go
Normal file
162
internal/ciscript/fakegitea_test.go
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
package ciscript_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// commitStatus is the part of an entry in Gitea's combined-status
|
||||||
|
// response that script/ci-mark-superseded reads.
|
||||||
|
type commitStatus struct {
|
||||||
|
Context string `json:"context"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// postedStatus is the part of a create-status request body the script
|
||||||
|
// writes.
|
||||||
|
type postedStatus struct {
|
||||||
|
Context string `json:"context"`
|
||||||
|
State string `json:"state"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeGitea serves the two endpoints the script talks to. Like Gitea,
|
||||||
|
// the newest status for a context replaces the previous one, so a
|
||||||
|
// second run of the script sees what the first one wrote.
|
||||||
|
type fakeGitea struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
statuses map[string][]commitStatus
|
||||||
|
posted map[string][]postedStatus
|
||||||
|
// failRead is a commit whose combined-status read answers HTTP
|
||||||
|
// 500, standing in for a status API that is down.
|
||||||
|
failRead string
|
||||||
|
}
|
||||||
|
|
||||||
|
// newFakeGitea returns the fake and the base URL to hand the script as
|
||||||
|
// GITHUB_API_URL.
|
||||||
|
func newFakeGitea(t *testing.T) (*fakeGitea, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
fake := &fakeGitea{
|
||||||
|
mu: sync.Mutex{},
|
||||||
|
statuses: map[string][]commitStatus{},
|
||||||
|
posted: map[string][]postedStatus{},
|
||||||
|
failRead: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := httptest.NewServer(fake.routes())
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
|
return fake, srv.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeGitea) routes() http.Handler {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
mux.HandleFunc(
|
||||||
|
"GET /repos/{owner}/{repo}/commits/{sha}/status",
|
||||||
|
f.handleCombined,
|
||||||
|
)
|
||||||
|
mux.HandleFunc(
|
||||||
|
"POST /repos/{owner}/{repo}/statuses/{sha}",
|
||||||
|
f.handleCreate,
|
||||||
|
)
|
||||||
|
|
||||||
|
return mux
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeGitea) handleCombined(
|
||||||
|
w http.ResponseWriter, r *http.Request,
|
||||||
|
) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
|
||||||
|
sha := r.PathValue("sha")
|
||||||
|
if f.failRead != "" && f.failRead == sha {
|
||||||
|
http.Error(w, "boom", http.StatusInternalServerError)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body := struct {
|
||||||
|
Statuses []commitStatus `json:"statuses"`
|
||||||
|
}{Statuses: f.statuses[sha]}
|
||||||
|
|
||||||
|
payload, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
_, _ = w.Write(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeGitea) handleCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var got postedStatus
|
||||||
|
|
||||||
|
err := json.NewDecoder(r.Body).Decode(&got)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sha := r.PathValue("sha")
|
||||||
|
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
|
||||||
|
f.posted[sha] = append(f.posted[sha], got)
|
||||||
|
f.replaceLocked(sha, commitStatus{
|
||||||
|
Context: got.Context,
|
||||||
|
Status: got.State,
|
||||||
|
Description: got.Description,
|
||||||
|
})
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
}
|
||||||
|
|
||||||
|
// failStatusRead makes the combined-status read for one commit answer
|
||||||
|
// HTTP 500.
|
||||||
|
func (f *fakeGitea) failStatusRead(sha string) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
|
||||||
|
f.failRead = sha
|
||||||
|
}
|
||||||
|
|
||||||
|
// setStatus gives a commit its latest status for a context.
|
||||||
|
func (f *fakeGitea) setStatus(sha string, status commitStatus) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
|
||||||
|
f.replaceLocked(sha, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// postedFor returns the statuses the script created for a commit.
|
||||||
|
func (f *fakeGitea) postedFor(sha string) []postedStatus {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
|
||||||
|
return append([]postedStatus(nil), f.posted[sha]...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// replaceLocked requires f.mu.
|
||||||
|
func (f *fakeGitea) replaceLocked(sha string, status commitStatus) {
|
||||||
|
for i, existing := range f.statuses[sha] {
|
||||||
|
if existing.Context == status.Context {
|
||||||
|
f.statuses[sha][i] = status
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
f.statuses[sha] = append(f.statuses[sha], status)
|
||||||
|
}
|
||||||
120
internal/handlers/event_log_view.go
Normal file
120
internal/handlers/event_log_view.go
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxRenderedBodyBytes caps how many bytes of a stored event
|
||||||
|
// body reach the event log page. Bodies come from the
|
||||||
|
// unauthenticated receiver under the 1 MB ingest cap and
|
||||||
|
// renderTemplate buffers a whole page before writing it, so
|
||||||
|
// an uncapped page of paginationPerPage events is tens of
|
||||||
|
// megabytes of resident memory per concurrent viewer.
|
||||||
|
const maxRenderedBodyBytes = 8192
|
||||||
|
|
||||||
|
// eventLogColumns is the event log's projection. The casts to
|
||||||
|
// blob are load-bearing: they make substr and length count
|
||||||
|
// bytes rather than characters, so the cap bounds the page in
|
||||||
|
// bytes whatever the payload's encoding. Cutting in SQLite
|
||||||
|
// rather than in Go is the point of the projection — an
|
||||||
|
// oversized body never becomes a Go string at all.
|
||||||
|
const eventLogColumns = "id, created_at, method, content_type, " +
|
||||||
|
"substr(cast(body as blob), 1, ?) AS body, " +
|
||||||
|
"length(cast(body as blob)) AS body_bytes"
|
||||||
|
|
||||||
|
// EventLogView is the display-safe projection of an event for
|
||||||
|
// the event log page, alongside DeliveryView and TargetView.
|
||||||
|
// It carries a capped body plus the true stored size, so the
|
||||||
|
// page can mark a body as truncated without ever holding the
|
||||||
|
// whole thing.
|
||||||
|
type EventLogView struct {
|
||||||
|
ID string
|
||||||
|
CreatedAt time.Time
|
||||||
|
Method string
|
||||||
|
ContentType string
|
||||||
|
|
||||||
|
// Body holds at most maxRenderedBodyBytes bytes of the
|
||||||
|
// stored body.
|
||||||
|
Body string
|
||||||
|
|
||||||
|
// BodyBytes is the true size of the stored body.
|
||||||
|
BodyBytes int64
|
||||||
|
|
||||||
|
// BodyTruncated reports that the stored body was larger
|
||||||
|
// than the cap, so the page owes the reader a marker.
|
||||||
|
BodyTruncated bool
|
||||||
|
|
||||||
|
Deliveries []DeliveryView
|
||||||
|
}
|
||||||
|
|
||||||
|
// BodyShownBytes is how many body bytes the page is actually
|
||||||
|
// rendering, which the truncation marker reports beside the
|
||||||
|
// true size.
|
||||||
|
func (v EventLogView) BodyShownBytes() int {
|
||||||
|
return len(v.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// eventLogRow is one row of the event log projection. Its
|
||||||
|
// body column arrives already cut to the cap by SQLite, with
|
||||||
|
// the true size beside it.
|
||||||
|
type eventLogRow struct {
|
||||||
|
ID string
|
||||||
|
CreatedAt time.Time
|
||||||
|
Method string
|
||||||
|
ContentType string
|
||||||
|
Body []byte
|
||||||
|
BodyBytes int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// view projects a loaded row for rendering.
|
||||||
|
func (r *eventLogRow) view() EventLogView {
|
||||||
|
body := r.Body
|
||||||
|
truncated := r.BodyBytes > int64(len(body))
|
||||||
|
|
||||||
|
// Only a cut body can have been left mid-sequence by
|
||||||
|
// this query. A whole body is passed through exactly as
|
||||||
|
// stored, however malformed.
|
||||||
|
if truncated {
|
||||||
|
body = trimPartialRune(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
return EventLogView{
|
||||||
|
ID: r.ID,
|
||||||
|
CreatedAt: r.CreatedAt,
|
||||||
|
Method: r.Method,
|
||||||
|
ContentType: r.ContentType,
|
||||||
|
Body: string(body),
|
||||||
|
BodyBytes: r.BodyBytes,
|
||||||
|
BodyTruncated: truncated,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// trimPartialRune drops a trailing UTF-8 sequence that the
|
||||||
|
// byte-wise cut left incomplete, so a multi-byte rune severed
|
||||||
|
// at the cap does not surface as a mojibake tail.
|
||||||
|
//
|
||||||
|
// Bytes that are merely invalid UTF-8 are left exactly as
|
||||||
|
// stored: this service receives binary payloads, and rewriting
|
||||||
|
// them would misreport what was delivered. The distinction is
|
||||||
|
// utf8.FullRune's — it reports a complete sequence for an
|
||||||
|
// invalid encoding too, since that decodes to a width-1 error
|
||||||
|
// rune, so only a valid prefix still waiting for its
|
||||||
|
// continuation bytes is removed. A tail with no rune start in
|
||||||
|
// its last utf8.UTFMax bytes cannot be an incomplete sequence
|
||||||
|
// either, and is likewise left alone.
|
||||||
|
func trimPartialRune(b []byte) []byte {
|
||||||
|
for i := len(b) - 1; i >= 0 && len(b)-i <= utf8.UTFMax; i-- {
|
||||||
|
if !utf8.RuneStart(b[i]) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if utf8.FullRune(b[i:]) {
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
return b[:i]
|
||||||
|
}
|
||||||
|
|
||||||
|
return b
|
||||||
|
}
|
||||||
258
internal/handlers/event_log_view_test.go
Normal file
258
internal/handlers/event_log_view_test.go
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
package handlers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
"sneak.berlin/go/webhooker/internal/handlers"
|
||||||
|
"sneak.berlin/go/webhooker/internal/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
// bodyCap is the number of body bytes the event log page is
|
||||||
|
// allowed to render for one event.
|
||||||
|
const bodyCap = handlers.MaxRenderedBodyBytesForTest
|
||||||
|
|
||||||
|
// snowman is a three-byte rune, so a body of them straddles the
|
||||||
|
// byte-wise cut: bodyCap is not a multiple of three.
|
||||||
|
const snowman = "☃"
|
||||||
|
|
||||||
|
// seedEventWithBody records one event with the given body in the
|
||||||
|
// webhook's own database.
|
||||||
|
func seedEventWithBody(
|
||||||
|
t *testing.T,
|
||||||
|
dbMgr *database.WebhookDBManager,
|
||||||
|
webhookID string,
|
||||||
|
body string,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
webhookDB, err := dbMgr.GetDB(webhookID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
event := &database.Event{
|
||||||
|
WebhookID: webhookID,
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Body: body,
|
||||||
|
ContentType: "application/octet-stream",
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, webhookDB.Omit(
|
||||||
|
clause.Associations,
|
||||||
|
).Create(event).Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedAndProject stores one body and returns the projection the
|
||||||
|
// event log page would be handed for it.
|
||||||
|
func seedAndProject(
|
||||||
|
t *testing.T,
|
||||||
|
body string,
|
||||||
|
) handlers.EventLogView {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var (
|
||||||
|
h *handlers.Handlers
|
||||||
|
sess *session.Session
|
||||||
|
db *database.Database
|
||||||
|
dbMgr *database.WebhookDBManager
|
||||||
|
)
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess, &db, &dbMgr)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
wh := seedWebhook(t, db)
|
||||||
|
seedEventWithBody(t, dbMgr, wh.ID, body)
|
||||||
|
|
||||||
|
views := h.LoadEventLogViewsForTest(
|
||||||
|
httptest.NewRecorder(), *wh, 1,
|
||||||
|
)
|
||||||
|
require.Len(t, views, 1)
|
||||||
|
|
||||||
|
return views[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleSourceLogs_BoundsOversizeBody proves the rendered
|
||||||
|
// page is bounded by the cap rather than by the stored payload:
|
||||||
|
// the body here is 64 times the cap, and the ingest path would
|
||||||
|
// accept twice as much again.
|
||||||
|
func TestHandleSourceLogs_BoundsOversizeBody(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
h *handlers.Handlers
|
||||||
|
sess *session.Session
|
||||||
|
db *database.Database
|
||||||
|
dbMgr *database.WebhookDBManager
|
||||||
|
)
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess, &db, &dbMgr)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
const (
|
||||||
|
sentinel = "TAIL-SENTINEL-1f4a9c"
|
||||||
|
storedBytes = 512 * 1024
|
||||||
|
)
|
||||||
|
|
||||||
|
wh := seedWebhook(t, db)
|
||||||
|
seedEventWithBody(
|
||||||
|
t, dbMgr, wh.ID,
|
||||||
|
strings.Repeat("A", storedBytes-len(sentinel))+sentinel,
|
||||||
|
)
|
||||||
|
|
||||||
|
page := renderSourceLogsPage(t, h, sess, wh.ID)
|
||||||
|
|
||||||
|
// Nothing past the cap reaches the page, and the whole page
|
||||||
|
// stays far below the stored body it is reporting on.
|
||||||
|
assert.NotContains(t, page, sentinel)
|
||||||
|
assert.Less(t, len(page), 4*bodyCap)
|
||||||
|
|
||||||
|
// The marker states the true stored size, not the cut one.
|
||||||
|
assert.Contains(
|
||||||
|
t, page,
|
||||||
|
"showing "+strconv.Itoa(bodyCap)+
|
||||||
|
" of "+strconv.Itoa(storedBytes)+" bytes",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleSourceLogs_SmallBodyRendersWhole guards the other
|
||||||
|
// side of the cap: a body under it is shown in full and carries
|
||||||
|
// no truncation marker.
|
||||||
|
func TestHandleSourceLogs_SmallBodyRendersWhole(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
h *handlers.Handlers
|
||||||
|
sess *session.Session
|
||||||
|
db *database.Database
|
||||||
|
dbMgr *database.WebhookDBManager
|
||||||
|
)
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess, &db, &dbMgr)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
wh := seedWebhook(t, db)
|
||||||
|
seedEventWithBody(t, dbMgr, wh.ID, `{"kept":"whole"}`)
|
||||||
|
|
||||||
|
page := renderSourceLogsPage(t, h, sess, wh.ID)
|
||||||
|
|
||||||
|
assert.Contains(t, page, ""kept"")
|
||||||
|
assert.NotContains(t, page, "Body truncated for display")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEventLogView_CutMidRune proves a multi-byte rune severed
|
||||||
|
// by the byte-wise cut is dropped rather than surfaced as a
|
||||||
|
// mojibake tail.
|
||||||
|
func TestEventLogView_CutMidRune(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
body := strings.Repeat(snowman, 4096)
|
||||||
|
view := seedAndProject(t, body)
|
||||||
|
|
||||||
|
// bodyCap bytes hold bodyCap/3 whole snowmen and two bytes
|
||||||
|
// of the next one; those two are dropped.
|
||||||
|
whole := bodyCap / len(snowman)
|
||||||
|
|
||||||
|
assert.True(t, view.BodyTruncated)
|
||||||
|
assert.Equal(t, int64(len(body)), view.BodyBytes)
|
||||||
|
assert.Equal(t, strings.Repeat(snowman, whole), view.Body)
|
||||||
|
assert.True(t, utf8.ValidString(view.Body))
|
||||||
|
assert.LessOrEqual(t, len(view.Body), bodyCap)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEventLogView_BinaryBodyLeftAsStored proves a binary
|
||||||
|
// payload is passed through byte for byte. Its tail is invalid
|
||||||
|
// UTF-8 however the cut falls, so repairing it would misreport
|
||||||
|
// what the sender delivered.
|
||||||
|
func TestEventLogView_BinaryBodyLeftAsStored(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
raw := make([]byte, bodyCap+808)
|
||||||
|
for i := range raw {
|
||||||
|
// 0x80..0xBF: continuation bytes, never a rune start.
|
||||||
|
raw[i] = 0x80 | byte(i%0x40)
|
||||||
|
}
|
||||||
|
|
||||||
|
view := seedAndProject(t, string(raw))
|
||||||
|
|
||||||
|
assert.True(t, view.BodyTruncated)
|
||||||
|
assert.Equal(t, int64(len(raw)), view.BodyBytes)
|
||||||
|
assert.Equal(t, string(raw[:bodyCap]), view.Body)
|
||||||
|
assert.False(t, utf8.ValidString(view.Body))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTrimPartialRune covers the distinction the cut repair
|
||||||
|
// turns on: an incomplete but valid sequence is dropped, while
|
||||||
|
// bytes that are merely invalid UTF-8 are left alone.
|
||||||
|
func TestTrimPartialRune(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
in []byte
|
||||||
|
want []byte
|
||||||
|
}{{
|
||||||
|
name: "complete ascii",
|
||||||
|
in: []byte("abc"),
|
||||||
|
want: []byte("abc"),
|
||||||
|
}, {
|
||||||
|
name: "complete multibyte",
|
||||||
|
in: []byte("ab" + snowman),
|
||||||
|
want: []byte("ab" + snowman),
|
||||||
|
}, {
|
||||||
|
name: "two byte rune cut",
|
||||||
|
in: []byte{'a', 0xC3},
|
||||||
|
want: []byte{'a'},
|
||||||
|
}, {
|
||||||
|
name: "three byte rune cut after one",
|
||||||
|
in: []byte{'a', 0xE2},
|
||||||
|
want: []byte{'a'},
|
||||||
|
}, {
|
||||||
|
name: "three byte rune cut after two",
|
||||||
|
in: []byte{'a', 0xE2, 0x98},
|
||||||
|
want: []byte{'a'},
|
||||||
|
}, {
|
||||||
|
name: "four byte rune cut",
|
||||||
|
in: []byte{'a', 0xF0, 0x9F, 0x92}, // U+1F4A9 cut
|
||||||
|
want: []byte{'a'},
|
||||||
|
}, {
|
||||||
|
name: "invalid start byte kept",
|
||||||
|
in: []byte{'a', 0xFF},
|
||||||
|
want: []byte{'a', 0xFF},
|
||||||
|
}, {
|
||||||
|
name: "orphan continuation bytes kept",
|
||||||
|
in: []byte{0x80, 0x81, 0x82, 0x83, 0x84},
|
||||||
|
want: []byte{0x80, 0x81, 0x82, 0x83, 0x84},
|
||||||
|
}, {
|
||||||
|
name: "truncated sequence followed by junk kept",
|
||||||
|
in: []byte{0xE2, 0x98, 0xFF},
|
||||||
|
want: []byte{0xE2, 0x98, 0xFF},
|
||||||
|
}, {
|
||||||
|
name: "empty",
|
||||||
|
in: []byte{},
|
||||||
|
want: []byte{},
|
||||||
|
}}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
assert.Equal(
|
||||||
|
t, tc.want,
|
||||||
|
handlers.TrimPartialRuneForTest(tc.in),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,8 +3,35 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"html/template"
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// MaxRenderedBodyBytesForTest exposes the event log's body cap
|
||||||
|
// to the handlers_test package.
|
||||||
|
const MaxRenderedBodyBytesForTest = maxRenderedBodyBytes
|
||||||
|
|
||||||
|
// TrimPartialRuneForTest exposes trimPartialRune for use in the
|
||||||
|
// handlers_test package.
|
||||||
|
func TrimPartialRuneForTest(b []byte) []byte {
|
||||||
|
return trimPartialRune(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadEventLogViewsForTest exposes loadEventsWithDeliveries for
|
||||||
|
// use in the handlers_test package. Assertions on the projected
|
||||||
|
// body need the bytes as loaded: html/template rewrites invalid
|
||||||
|
// UTF-8 on the way out, so the rendered page cannot show whether
|
||||||
|
// a binary body survived the projection intact.
|
||||||
|
func (s *Handlers) LoadEventLogViewsForTest(
|
||||||
|
w http.ResponseWriter,
|
||||||
|
webhook database.Webhook,
|
||||||
|
page int,
|
||||||
|
) []EventLogView {
|
||||||
|
views, _ := s.loadEventsWithDeliveries(w, webhook, nil, page)
|
||||||
|
|
||||||
|
return views
|
||||||
|
}
|
||||||
|
|
||||||
// AddTemplateForTest registers a template under a page name so that
|
// AddTemplateForTest registers a template under a page name so that
|
||||||
// the handlers_test package can drive the render path with a
|
// the handlers_test package can drive the render path with a
|
||||||
// template of its own.
|
// template of its own.
|
||||||
|
|||||||
@@ -229,8 +229,10 @@ func (s *Handlers) renderTemplate(
|
|||||||
// the response only once rendering has fully succeeded. Executing
|
// the response only once rendering has fully succeeded. Executing
|
||||||
// straight into the ResponseWriter commits a partial body and a 200
|
// straight into the ResponseWriter commits a partial body and a 200
|
||||||
// status before a mid-render error can be reported, leaving no way
|
// status before a mid-render error can be reported, leaving no way
|
||||||
// to serve a 500. These pages are small, so holding one in memory is
|
// to serve a 500. Buffering makes a page's rendered size resident
|
||||||
// the right trade.
|
// memory per concurrent viewer, so every page owes it a bound: the
|
||||||
|
// event log caps each stored body at maxRenderedBodyBytes for exactly
|
||||||
|
// this reason.
|
||||||
func (s *Handlers) executeTemplate(
|
func (s *Handlers) executeTemplate(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
tmpl *template.Template,
|
tmpl *template.Template,
|
||||||
|
|||||||
@@ -92,13 +92,6 @@ func parseRetentionDays(raw string, fallback int) (int, error) {
|
|||||||
return v, nil
|
return v, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// EventWithDeliveries holds an event and its deliveries.
|
|
||||||
type EventWithDeliveries struct {
|
|
||||||
database.Event
|
|
||||||
|
|
||||||
Deliveries []DeliveryView
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeliveryView is the display-safe projection of a delivery
|
// DeliveryView is the display-safe projection of a delivery
|
||||||
// for the event log page. Its target is a TargetView, so the
|
// for the event log page. Its target is a TargetView, so the
|
||||||
// stored configuration blob — which holds the target's
|
// stored configuration blob — which holds the target's
|
||||||
@@ -815,16 +808,18 @@ func (h *Handlers) parsePage(r *http.Request) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// loadEventsWithDeliveries loads paginated events and their
|
// loadEventsWithDeliveries loads paginated events and their
|
||||||
// deliveries from the per-webhook database.
|
// deliveries from the per-webhook database. Events come back
|
||||||
|
// as capped projections rather than database.Event rows: see
|
||||||
|
// eventLogColumns for why the cut happens in SQL.
|
||||||
func (h *Handlers) loadEventsWithDeliveries(
|
func (h *Handlers) loadEventsWithDeliveries(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
webhook database.Webhook,
|
webhook database.Webhook,
|
||||||
targetMap map[string]delivery.TargetView,
|
targetMap map[string]delivery.TargetView,
|
||||||
page int,
|
page int,
|
||||||
) ([]EventWithDeliveries, int64) {
|
) ([]EventLogView, int64) {
|
||||||
var totalEvents int64
|
var totalEvents int64
|
||||||
|
|
||||||
var result []EventWithDeliveries
|
var result []EventLogView
|
||||||
|
|
||||||
if !h.dbMgr.DBExists(webhook.ID) {
|
if !h.dbMgr.DBExists(webhook.ID) {
|
||||||
return result, totalEvents
|
return result, totalEvents
|
||||||
@@ -845,23 +840,25 @@ func (h *Handlers) loadEventsWithDeliveries(
|
|||||||
|
|
||||||
offset := (page - 1) * paginationPerPage
|
offset := (page - 1) * paginationPerPage
|
||||||
|
|
||||||
var events []database.Event
|
var rows []eventLogRow
|
||||||
|
|
||||||
webhookDB.Where(
|
webhookDB.Model(&database.Event{}).Select(
|
||||||
|
eventLogColumns, maxRenderedBodyBytes,
|
||||||
|
).Where(
|
||||||
"webhook_id = ?", webhook.ID,
|
"webhook_id = ?", webhook.ID,
|
||||||
).Order("created_at DESC").Offset(offset).Limit(
|
).Order("created_at DESC").Offset(offset).Limit(
|
||||||
paginationPerPage,
|
paginationPerPage,
|
||||||
).Find(&events)
|
).Find(&rows)
|
||||||
|
|
||||||
result = make([]EventWithDeliveries, len(events))
|
result = make([]EventLogView, len(rows))
|
||||||
|
|
||||||
for i := range events {
|
for i := range rows {
|
||||||
result[i].Event = events[i]
|
result[i] = rows[i].view()
|
||||||
|
|
||||||
var deliveries []database.Delivery
|
var deliveries []database.Delivery
|
||||||
|
|
||||||
webhookDB.Where(
|
webhookDB.Where(
|
||||||
"event_id = ?", events[i].ID,
|
"event_id = ?", rows[i].ID,
|
||||||
).Find(&deliveries)
|
).Find(&deliveries)
|
||||||
|
|
||||||
result[i].Deliveries = newDeliveryViews(
|
result[i].Deliveries = newDeliveryViews(
|
||||||
|
|||||||
50
internal/server/static_assets_test.go
Normal file
50
internal/server/static_assets_test.go
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package server_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"sneak.berlin/go/webhooker/templates"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestBaseTemplateScriptsAreServed walks every /s/ script the base
|
||||||
|
// template loads on each page and fetches it through the real router.
|
||||||
|
// Alpine.js is fetched at build time rather than committed, so nothing
|
||||||
|
// in the repo guarantees it is present: this is the check that the page
|
||||||
|
// still gets the JavaScript it asks for.
|
||||||
|
func TestBaseTemplateScriptsAreServed(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// scriptSrc matches the src of every <script> tag pointing at the
|
||||||
|
// /s/ static mount.
|
||||||
|
scriptSrc := regexp.MustCompile(`<script[^>]+src="(/s/[^"]+)"`)
|
||||||
|
|
||||||
|
base, err := templates.Templates.ReadFile("base.html")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
matches := scriptSrc.FindAllStringSubmatch(string(base), -1)
|
||||||
|
require.NotEmpty(t, matches, "base.html should load scripts from /s/")
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
|
||||||
|
for _, m := range matches {
|
||||||
|
src := m[1]
|
||||||
|
t.Run(src, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
w := env.get(src, nil)
|
||||||
|
|
||||||
|
require.Equalf(
|
||||||
|
t, http.StatusOK, w.Code,
|
||||||
|
"base.html loads %s but the server does not serve it", src,
|
||||||
|
)
|
||||||
|
assert.NotEmptyf(
|
||||||
|
t, w.Body.Bytes(), "%s is served but empty", src,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,8 @@
|
|||||||
# or apk (detected in that order); assumes NOTHING is present (not git,
|
# or apk (detected in that order); assumes NOTHING is present (not git,
|
||||||
# make, or go). golangci-lint is packaged in nix, brew, and apk; on apt
|
# make, or go). golangci-lint is packaged in nix, brew, and apk; on apt
|
||||||
# it is installed from a hash-verified GitHub release archive (never
|
# it is installed from a hash-verified GitHub release archive (never
|
||||||
# curl | sh).
|
# curl | sh). Finishes by running script/fetch-assets, which installs the
|
||||||
|
# hash-pinned third-party browser assets the repo does not commit.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
@@ -115,6 +116,11 @@ main() {
|
|||||||
|
|
||||||
go mod download
|
go mod download
|
||||||
|
|
||||||
|
# Third-party browser assets are not committed; fetch and verify them
|
||||||
|
# so a fresh clone can build and test.
|
||||||
|
if missing curl; then pkg_install curl curl curl curl; fi
|
||||||
|
"$ROOT/script/fetch-assets"
|
||||||
|
|
||||||
echo "bootstrap complete"
|
echo "bootstrap complete"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
136
script/ci-mark-superseded
Executable file
136
script/ci-mark-superseded
Executable file
@@ -0,0 +1,136 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# script/ci-mark-superseded: record an honest status on commits whose CI
|
||||||
|
# run Gitea cancelled because a newer commit landed on the same branch.
|
||||||
|
# Gitea writes `failure` / "Has been cancelled" for such a run, which
|
||||||
|
# reads as a test result on a commit nothing ever tested. Cancellation is
|
||||||
|
# unconditional server-side for push events, so the superseding run
|
||||||
|
# rewrites those statuses to `failure` with a description that says the
|
||||||
|
# commit was never tested. `skipped` cannot be used: Gitea's combined
|
||||||
|
# status folds `skipped` into `success`, so a never-tested commit would
|
||||||
|
# report green. Genuine failures and successes are never touched.
|
||||||
|
#
|
||||||
|
# Called by the Gitea Actions workflow, which supplies GITHUB_API_URL,
|
||||||
|
# GITHUB_REPOSITORY, GITHUB_SHA, GITHUB_WORKFLOW, GITHUB_JOB,
|
||||||
|
# GITHUB_EVENT_NAME and GITEA_TOKEN. ANCESTOR_LIMIT (default 20) caps how
|
||||||
|
# far back the walk looks; a value that is set but not a positive integer
|
||||||
|
# aborts rather than silently disabling the walk.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
SUPERSEDED_DESC='Superseded by a newer commit; never tested'
|
||||||
|
|
||||||
|
# Gitea builds the commit-status context as
|
||||||
|
# "<workflow name> / <job name> (<event>)", so derive it rather than
|
||||||
|
# hardcoding the result.
|
||||||
|
#
|
||||||
|
# The derivation is deliberately not byte-exact with Gitea's own rule and
|
||||||
|
# must not be "fixed" into a silent fallback. Gitea uses the job's `name:`
|
||||||
|
# (falling back to the job id) and the workflow's `name:` (falling back to
|
||||||
|
# the workflow filename), while the runner exports GITHUB_JOB as the job
|
||||||
|
# *id* and GITHUB_WORKFLOW as the parsed workflow `name:`. So giving the
|
||||||
|
# job a display `name:`, or dropping the workflow's `name:`, makes the
|
||||||
|
# derived context stop matching --- and require_own_context below then
|
||||||
|
# turns every push red with a message. That loud failure is the point
|
||||||
|
# (https://git.eeqj.de/sneak/webhooker/issues/147 item 2); guessing at a
|
||||||
|
# fallback would restore the silent no-op it replaced.
|
||||||
|
context() {
|
||||||
|
printf '%s / %s (%s)' \
|
||||||
|
"$GITHUB_WORKFLOW" "$GITHUB_JOB" "$GITHUB_EVENT_NAME"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ANCESTOR_LIMIT is a documented knob, so a value that is set but
|
||||||
|
# unusable must fail loudly instead of defaulting
|
||||||
|
# (https://git.eeqj.de/sneak/webhooker/issues/80). Passing it straight to
|
||||||
|
# git would print `fatal: not an integer` into a discarded exit status
|
||||||
|
# and mark nothing.
|
||||||
|
ancestor_limit() {
|
||||||
|
_limit="${ANCESTOR_LIMIT:-20}"
|
||||||
|
case "$_limit" in
|
||||||
|
'' | *[!0-9]* | 0*)
|
||||||
|
echo "ANCESTOR_LIMIT must be a positive integer," \
|
||||||
|
"got '${_limit}'" >&2
|
||||||
|
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
printf '%s' "$_limit"
|
||||||
|
}
|
||||||
|
|
||||||
|
# The status Gitea created for this very job proves which context string
|
||||||
|
# it uses. If the derived one is missing, the workflow or the job was
|
||||||
|
# renamed and the match below would silently stop firing, restoring the
|
||||||
|
# false-red bug with no signal. Fail loudly instead.
|
||||||
|
require_own_context() {
|
||||||
|
if ! _body="$(curl -sf --retry 3 --retry-delay 2 --max-time 30 \
|
||||||
|
"${1}/commits/${GITHUB_SHA}/status")"; then
|
||||||
|
echo "cannot read commit statuses for ${GITHUB_SHA}" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
_found="$(printf '%s' "$_body" | jq -r '(.statuses // [])[].context')"
|
||||||
|
if printf '%s\n' "$_found" | grep -qxF "$2"; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "no commit status with context '${2}' on ${GITHUB_SHA}:" >&2
|
||||||
|
echo "workflow or job renamed? contexts present:" >&2
|
||||||
|
printf '%s\n' "$_found" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Latest status for our context on a commit, as "state|description".
|
||||||
|
# The read is retried and bounded, and a read that still fails aborts the
|
||||||
|
# step: a laundered commit that cannot be read is not the same as one
|
||||||
|
# with nothing to do, and piping curl into jq would discard the
|
||||||
|
# difference.
|
||||||
|
status_of() {
|
||||||
|
if ! _sbody="$(curl -sf --retry 3 --retry-delay 2 --max-time 30 \
|
||||||
|
"${1}/commits/${2}/status")"; then
|
||||||
|
echo "cannot read commit statuses for ${2}" >&2
|
||||||
|
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
printf '%s' "$_sbody" | jq -r --arg c "$3" \
|
||||||
|
'[(.statuses // [])[] | select(.context == $c)][0] // empty
|
||||||
|
| "\(.status)|\(.description)"'
|
||||||
|
}
|
||||||
|
|
||||||
|
mark_superseded() {
|
||||||
|
curl -sf -X POST "${1}/statuses/${2}" \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "$(jq -nc --arg c "$3" --arg d "$SUPERSEDED_DESC" \
|
||||||
|
'{context: $c, state: "failure", description: $d}')" \
|
||||||
|
>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
_api="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}"
|
||||||
|
_ctx="$(context)"
|
||||||
|
|
||||||
|
_limit="$(ancestor_limit)"
|
||||||
|
|
||||||
|
require_own_context "$_api" "$_ctx"
|
||||||
|
|
||||||
|
# A root commit legitimately has no ancestors; every other rev-list
|
||||||
|
# failure (a shallow clone, an unknown SHA) must abort, so the walk
|
||||||
|
# itself carries no `|| true`.
|
||||||
|
if ! git rev-parse -q --verify "${GITHUB_SHA}^" >/dev/null; then
|
||||||
|
echo "no ancestor of ${GITHUB_SHA} to check"
|
||||||
|
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
_walk="$(git rev-list --max-count="$_limit" "${GITHUB_SHA}^")"
|
||||||
|
|
||||||
|
for _sha in $_walk; do
|
||||||
|
_latest="$(status_of "$_api" "$_sha" "$_ctx")"
|
||||||
|
# A run that was cancelled, or one an earlier revision of this
|
||||||
|
# script laundered into `skipped`. Anything else stands.
|
||||||
|
case "$_latest" in
|
||||||
|
'failure|Has been cancelled' | "skipped|${SUPERSEDED_DESC}") ;;
|
||||||
|
*) continue ;;
|
||||||
|
esac
|
||||||
|
mark_superseded "$_api" "$_sha" "$_ctx"
|
||||||
|
echo "marked superseded: ${_sha}"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
104
script/fetch-assets
Executable file
104
script/fetch-assets
Executable file
@@ -0,0 +1,104 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# script/fetch-assets: download the third-party browser assets the web UI
|
||||||
|
# ships and install them under static/. Minified bundles are not committed
|
||||||
|
# (REPO_POLICIES.md: no build artifacts in version control), so the build
|
||||||
|
# fetches them here. Every download is verified against a hardcoded sha256
|
||||||
|
# before it is installed, and any mismatch aborts. Idempotent: an asset
|
||||||
|
# already present with its pinned hash is left alone.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
|
# The sha256 of each installed asset lives in static/vendor.sha256, in
|
||||||
|
# sha256sum(1) format, with paths relative to static/. That file is the
|
||||||
|
# single source of truth: this script verifies against it, and
|
||||||
|
# static/vendor_test.go asserts the bytes embedded into the binary match
|
||||||
|
# it, so the hash cannot rot into a value nothing checks.
|
||||||
|
MANIFEST="static/vendor.sha256"
|
||||||
|
|
||||||
|
# Alpine.js 3.14.9, 2026-08-17. Fetched from registry.npmjs.org, the
|
||||||
|
# publisher of record; the jsDelivr and unpkg copies are mirrors of this
|
||||||
|
# same tarball. dist/cdn.min.js is the browser build Alpine publishes for
|
||||||
|
# a <script> tag.
|
||||||
|
ALPINE_VERSION="3.14.9"
|
||||||
|
ALPINE_URL="https://registry.npmjs.org/alpinejs/-/alpinejs-${ALPINE_VERSION}.tgz"
|
||||||
|
# sha256 of alpinejs-3.14.9.tgz
|
||||||
|
ALPINE_TARBALL_SHA256="97dad7c0c81e659cfc8e7700055da9770f8186187cb9a8a76efb57e00d5ce52a"
|
||||||
|
ALPINE_MEMBER="package/dist/cdn.min.js"
|
||||||
|
ALPINE_DEST="js/alpine.min.js"
|
||||||
|
|
||||||
|
sha256_of() {
|
||||||
|
if command -v sha256sum >/dev/null 2>&1; then
|
||||||
|
sha256sum "$1" | cut -d' ' -f1
|
||||||
|
else
|
||||||
|
shasum -a 256 "$1" | cut -d' ' -f1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# expected_sha256 <path-relative-to-static>
|
||||||
|
expected_sha256() {
|
||||||
|
awk -v want="$1" '$2 == want { print $1; found = 1 }
|
||||||
|
END { if (!found) exit 1 }' "$ROOT/$MANIFEST"
|
||||||
|
}
|
||||||
|
|
||||||
|
# verify <file> <expected-sha256> <what>
|
||||||
|
verify() {
|
||||||
|
actual="$(sha256_of "$1")"
|
||||||
|
if [ "$actual" != "$2" ]; then
|
||||||
|
echo "fetch-assets: sha256 mismatch for $3" >&2
|
||||||
|
echo " expected: $2" >&2
|
||||||
|
echo " actual: $actual" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# up_to_date <path-relative-to-static> <expected-sha256>
|
||||||
|
up_to_date() {
|
||||||
|
[ -f "$ROOT/static/$1" ] || return 1
|
||||||
|
[ "$(sha256_of "$ROOT/static/$1")" = "$2" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch_alpine() {
|
||||||
|
want="$(expected_sha256 "$ALPINE_DEST")"
|
||||||
|
|
||||||
|
if up_to_date "$ALPINE_DEST" "$want"; then
|
||||||
|
echo "fetch-assets: static/$ALPINE_DEST already at $want"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "fetch-assets: fetching Alpine.js $ALPINE_VERSION from $ALPINE_URL"
|
||||||
|
tmp="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$tmp"' EXIT INT TERM
|
||||||
|
curl -fsSL -o "$tmp/alpine.tgz" "$ALPINE_URL"
|
||||||
|
verify "$tmp/alpine.tgz" "$ALPINE_TARBALL_SHA256" "alpinejs-${ALPINE_VERSION}.tgz"
|
||||||
|
tar -xzOf "$tmp/alpine.tgz" "$ALPINE_MEMBER" >"$tmp/alpine.min.js"
|
||||||
|
verify "$tmp/alpine.min.js" "$want" "$ALPINE_MEMBER from alpinejs-${ALPINE_VERSION}.tgz"
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$ROOT/static/$ALPINE_DEST")"
|
||||||
|
cp "$tmp/alpine.min.js" "$ROOT/static/$ALPINE_DEST"
|
||||||
|
rm -rf "$tmp"
|
||||||
|
trap - EXIT INT TERM
|
||||||
|
echo "fetch-assets: installed static/$ALPINE_DEST ($want)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Re-check every manifest entry against what is now on disk, so an entry
|
||||||
|
# no script installs fails loudly instead of passing silently.
|
||||||
|
verify_manifest() {
|
||||||
|
while read -r want path; do
|
||||||
|
case "$want" in '' | '#'*) continue ;; esac
|
||||||
|
if [ ! -f "$ROOT/static/$path" ]; then
|
||||||
|
echo "fetch-assets: $MANIFEST lists static/$path, which is missing" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
verify "$ROOT/static/$path" "$want" "static/$path"
|
||||||
|
done <"$ROOT/$MANIFEST"
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
cd "$ROOT"
|
||||||
|
fetch_alpine
|
||||||
|
verify_manifest
|
||||||
|
echo "fetch-assets: all assets in $MANIFEST verified"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
5
static/js/alpine.min.js
vendored
5
static/js/alpine.min.js
vendored
File diff suppressed because one or more lines are too long
1
static/vendor.sha256
Normal file
1
static/vendor.sha256
Normal file
@@ -0,0 +1 @@
|
|||||||
|
3ed1eed252488921df65e363d6715deb04d7f92aaedb9e52199fdf73cb1e0ad3 js/alpine.min.js
|
||||||
92
static/vendor_test.go
Normal file
92
static/vendor_test.go
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
package static_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"sneak.berlin/go/webhooker/static"
|
||||||
|
)
|
||||||
|
|
||||||
|
const manifestPath = "vendor.sha256"
|
||||||
|
|
||||||
|
// fetchHint is appended to every failure here: the assets the manifest
|
||||||
|
// covers are fetched by the build, not committed, so a fresh clone that
|
||||||
|
// has not run script/fetch-assets fails this test and should be told why.
|
||||||
|
const fetchHint = "run `script/fetch-assets` (or `make assets`) to install " +
|
||||||
|
"the pinned third-party assets"
|
||||||
|
|
||||||
|
// TestVendoredAssetsMatchManifest asserts that every asset listed in
|
||||||
|
// static/vendor.sha256 is embedded in the binary with exactly the pinned
|
||||||
|
// bytes. script/fetch-assets verifies the same hashes at download time;
|
||||||
|
// this test verifies them again on what actually ships, so a build that
|
||||||
|
// skipped, cached, or subverted the fetch cannot produce a binary serving
|
||||||
|
// unpinned third-party JavaScript.
|
||||||
|
func TestVendoredAssetsMatchManifest(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
entries := readManifest(t)
|
||||||
|
require.NotEmpty(t, entries, "%s lists no assets", manifestPath)
|
||||||
|
|
||||||
|
for path, want := range entries {
|
||||||
|
t.Run(path, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
data, err := static.Static.ReadFile(path)
|
||||||
|
require.NoErrorf(
|
||||||
|
t, err,
|
||||||
|
"%s is listed in %s but is not embedded; %s",
|
||||||
|
path, manifestPath, fetchHint,
|
||||||
|
)
|
||||||
|
|
||||||
|
sum := sha256.Sum256(data)
|
||||||
|
got := hex.EncodeToString(sum[:])
|
||||||
|
require.Equalf(
|
||||||
|
t, want, got,
|
||||||
|
"embedded %s does not match its pinned sha256 in %s; %s",
|
||||||
|
path, manifestPath, fetchHint,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readManifest parses static/vendor.sha256, which is in sha256sum(1)
|
||||||
|
// format with paths relative to static/.
|
||||||
|
func readManifest(t *testing.T) map[string]string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
f, err := os.Open(manifestPath)
|
||||||
|
require.NoError(t, err, "opening %s", manifestPath)
|
||||||
|
|
||||||
|
defer func() { require.NoError(t, f.Close()) }()
|
||||||
|
|
||||||
|
entries := make(map[string]string)
|
||||||
|
scanner := bufio.NewScanner(f)
|
||||||
|
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
require.Lenf(
|
||||||
|
t, fields, 2,
|
||||||
|
"%s: malformed entry %q, want \"<sha256> <path>\"",
|
||||||
|
manifestPath, line,
|
||||||
|
)
|
||||||
|
|
||||||
|
sum, path := fields[0], fields[1]
|
||||||
|
require.Lenf(t, sum, 64, "%s: %q is not a sha256", manifestPath, sum)
|
||||||
|
entries[path] = sum
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, scanner.Err(), "reading %s", manifestPath)
|
||||||
|
|
||||||
|
return entries
|
||||||
|
}
|
||||||
@@ -37,6 +37,9 @@
|
|||||||
|
|
||||||
<div x-show="open" x-cloak class="mt-3 p-3 bg-gray-50 rounded-md">
|
<div x-show="open" x-cloak class="mt-3 p-3 bg-gray-50 rounded-md">
|
||||||
<pre class="text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap break-all">{{.Body}}</pre>
|
<pre class="text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap break-all">{{.Body}}</pre>
|
||||||
|
{{if .BodyTruncated}}
|
||||||
|
<p class="mt-2 text-xs text-gray-500">Body truncated for display: showing {{.BodyShownBytes}} of {{.BodyBytes}} bytes. The stored body is unchanged.</p>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
|
|||||||
Reference in New Issue
Block a user