Repository navigation
Expand file tree
/
Copy pathTaskfile.yml
More file actions
596 lines (541 loc) · 26.8 KB
/
Copy pathTaskfile.yml
File metadata and controls
596 lines (541 loc) · 26.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
version: "3"
vars:
BINARY: devcontainer
MODULE: github.com/devcontainers/cli
# CalVer: YYYYMMDD.<build>. BUILD_NUMBER (e.g. a daily counter or CI run number)
# defaults to 01 for local builds. Tagged releases use the git tag verbatim.
VERSION:
sh: echo "$(git describe --tags --exact-match 2>/dev/null || echo "$(date -u +%Y%m%d).${BUILD_NUMBER:-01}")"
LDFLAGS: -s -w -X {{.MODULE}}/internal/product.version={{.VERSION}} -X {{.MODULE}}/internal/product.name={{.BINARY}}
# Minimum hermetic-unit statement coverage the `coverage` gate enforces. Set as a
# ratchet just below the current level; raise it as coverage grows (never lower).
COVERAGE_MIN: "52"
# Package ratchets prevent well-covered parsing packages from masking a
# regression in the orchestration layers. Keep these just below the measured
# hermetic baseline and only move them upward.
COVERAGE_CLI_MIN: "32"
COVERAGE_DOCKER_MIN: "77"
COVERAGE_LIFECYCLE_MIN: "73"
COVERAGE_OCI_MIN: "85"
# Coverage artifact layout. Every lane writes binary covdata under COVER_DATA/<lane>
# so `coverage:merge` can union them into the true cross-lane number.
COVER_DIR: artifacts/coverage
COVER_DATA: artifacts/coverage/data
# The image GoReleaser publishes (.goreleaser.yml's dockers), tagged with the version.
IMAGE: ghcr.io/spin-stack/devcontainer-cli
tasks:
default:
desc: Build and test the Go CLI
cmds:
- task: test:unit
- task: test:integration
- task: build
build:
desc: Build the static Go CLI
env:
CGO_ENABLED: "0"
cmds:
- go build -ldflags "{{.LDFLAGS}}" -o {{.BINARY}} ./cmd/devcontainer/
sources: [cmd/**/*.go, internal/**/*.go, go.mod, go.sum]
generates: ["{{.BINARY}}"]
build:coverage:
desc: Build an instrumented CLI for subprocess/integration coverage
env:
CGO_ENABLED: "0"
cmds:
- mkdir -p artifacts/coverage/bin artifacts/coverage/data
- go build -cover -ldflags "{{.LDFLAGS}}" -o artifacts/coverage/bin/{{.BINARY}} ./cmd/devcontainer/
build:cross:
desc: Cross-compile release binaries
env:
CGO_ENABLED: "0"
cmds:
- mkdir -p dist
# Linux-only scope: only linux/amd64 + linux/arm64 are supported targets.
- GOOS=linux GOARCH=amd64 go build -ldflags "{{.LDFLAGS}}" -o dist/{{.BINARY}}-linux-amd64 ./cmd/devcontainer/
- GOOS=linux GOARCH=arm64 go build -ldflags "{{.LDFLAGS}}" -o dist/{{.BINARY}}-linux-arm64 ./cmd/devcontainer/
- ls -lh dist/
build:smoke:
desc: Build the CLI and check the binary runs
deps: [build]
cmds:
- ./{{.BINARY}} --version
test:unit:
desc: Run hermetic unit tests
cmds:
- go test ./cmd/... ./internal/... -skip '^TestClient$|^TestE2E|^TestParityMatrix$|^TestPublishParity$' -count=1 {{.CLI_ARGS}}
test:integration:
desc: Run local HTTP integration tests
cmds:
- go test ./internal/httpx -run TestClient -count=1 {{.CLI_ARGS}}
test:race:
desc: Run hermetic unit tests under the race detector (needs cgo)
env:
CGO_ENABLED: "1"
cmds:
- go test ./cmd/... ./internal/... -skip '^TestClient$|^TestE2E|^TestParityMatrix$|^TestPublishParity$' -race -count=1 {{.CLI_ARGS}}
vuln:
desc: Scan module and code paths for known vulnerabilities (govulncheck)
cmds:
- go run golang.org/x/vuln/cmd/govulncheck@latest ./...
coverage:
desc: Hermetic unit coverage — per-package table + enforce the total/package floors
cmds:
# rm -rf first: covdata files accumulate across runs and would inflate the
# merged number otherwise.
- rm -rf {{.COVER_DATA}}/unit && mkdir -p {{.COVER_DATA}}/unit
# -coverprofile keeps coverage.out byte-identical for the gate; the extra
# -test.gocoverdir also emits binary covdata so `coverage:merge` can union it
# with the e2e/parity lanes.
- go test ./cmd/... ./internal/... -skip '^TestClient$|^TestE2E|^TestParityMatrix$|^TestPublishParity$' -coverprofile=coverage.out -count=1 -args -test.gocoverdir={{.ROOT_DIR}}/{{.COVER_DATA}}/unit
- go run ./cmd/devtool coverage-report coverage.out "unit (hermetic)"
# Ratchet: total + per-package floors (parsers must not mask lost CLI/OCI coverage).
- go run ./cmd/devtool coverage-gate coverage.out TOTAL={{.COVERAGE_MIN}} cli={{.COVERAGE_CLI_MIN}} docker={{.COVERAGE_DOCKER_MIN}} lifecycle={{.COVERAGE_LIFECYCLE_MIN}} oci={{.COVERAGE_OCI_MIN}}
coverage:e2e:
desc: Coverage of the real in-process E2E paths (requires Docker)
preconditions:
- sh: docker info >/dev/null 2>&1
msg: Docker is required for E2E coverage
cmds:
- rm -rf {{.COVER_DATA}}/e2e && mkdir -p {{.COVER_DATA}}/e2e
# -coverpkg instruments the whole module (these tests invoke NewRootCommand
# directly); -test.gocoverdir emits binary covdata for the merge.
- go test ./internal/cli -run '^TestE2E' -count=1 -timeout 5m -coverpkg=./... -args -test.gocoverdir={{.ROOT_DIR}}/{{.COVER_DATA}}/e2e
- go run ./cmd/devtool coverage-report {{.COVER_DATA}}/e2e "e2e"
# coverage:_parity-lane runs the instrumented CLI (build:coverage) through a
# parity lane, collecting covdata into COVER_DATA/<LANE> and printing the table.
# LANE and RUN (the full `go test ...` invocation) are supplied by the wrappers.
coverage:_parity-lane:
internal: true
deps: [build:coverage, reference:compile]
cmds:
- rm -rf {{.COVER_DATA}}/{{.LANE}} && mkdir -p {{.COVER_DATA}}/{{.LANE}}
- GOCOVERDIR={{.ROOT_DIR}}/{{.COVER_DATA}}/{{.LANE}} CLI_GO={{.ROOT_DIR}}/{{.COVER_DIR}}/bin/{{.BINARY}} {{.RUN}}
- go run ./cmd/devtool coverage-report {{.COVER_DATA}}/{{.LANE}} "parity:{{.LANE}}"
coverage:parity-contract:
desc: Coverage of the instrumented binary through the hermetic contract parity lane
cmds:
- task: coverage:_parity-lane
vars:
LANE: contract
RUN: >-
PARITY_REPORT_FILE={{.ROOT_DIR}}/{{.COVER_DIR}}/parity-contract.json
PARITY_STRICT=true PARITY_LANE=contract PARITY_SKIP_DOCKER=true PARITY_SKIP_NETWORK=true
go test ./internal/cli -run '^TestParityMatrix$' -count=1 -timeout 10m -v
coverage:parity-runtime:
desc: Coverage of the instrumented binary through the full Docker parity lane
preconditions:
- sh: docker info >/dev/null 2>&1
msg: Docker is required for runtime parity coverage
- sh: docker info 2>/dev/null | grep -q io.containerd.snapshotter
msg: The parity runtime lane requires the containerd image store (docker build --platform on local images); enable it with `task ci:prepare-runner`.
- sh: go run ./cmd/devtool docker-cache-export
msg: The Docker builder must support cache export (see `devtool ci-prepare-runner`)
cmds:
- task: coverage:_parity-lane
vars:
LANE: runtime
RUN: >-
PARITY_REPORT_FILE={{.ROOT_DIR}}/{{.COVER_DIR}}/parity-runtime.json
PARITY_STRICT=true PARITY_LANE=all
go test ./internal/cli -run '^TestParityMatrix$' -count=1 -parallel {{.PARITY_PARALLEL | default "2"}} -timeout 60m -v
coverage:parity-publish:
desc: Coverage of real feature/template publication against an ephemeral OCI registry
preconditions:
- sh: docker info >/dev/null 2>&1
msg: Docker is required for publish parity coverage
cmds:
- task: coverage:_parity-lane
vars:
LANE: publish
RUN: go test ./internal/cli -run '^TestPublishParity$' -count=1 -timeout 10m -v
coverage:merge:
desc: Union every per-lane covdata dir into the true cross-lane coverage report (and the CI job summary)
cmds:
- go run ./cmd/devtool coverage-merge {{.COVER_DATA}} {{.COVER_DATA}}/merged "merged (all lanes)" | tee -a "${GITHUB_STEP_SUMMARY:-/dev/null}"
coverage:summary:
desc: Add one lane's coverage report (SRC, a coverage.out or covdata dir, titled TITLE) to the CI job summary, or print it
requires:
vars: [SRC, TITLE]
cmds:
- go run ./cmd/devtool coverage-report {{.SRC}} "{{.TITLE}}" >> "${GITHUB_STEP_SUMMARY:-/dev/stdout}"
# download-artifact nests each artifact under covdata-<lane>/; coverage:merge wants one dir per
# lane, the runtime shards still apart.
coverage:unpack:
desc: "CI-ONLY: flatten the downloaded covdata-<lane>/ artifacts into the <lane>/ dirs coverage:merge reads"
dir: "{{.COVER_DATA}}"
cmds:
- |
for d in covdata-*; do [ -d "$d" ] && mv "$d" "${d#covdata-}"; done
ls -R . || true
test:e2e:
desc: Run Go E2E tests with Docker
deps: [build]
preconditions:
- sh: docker info >/dev/null 2>&1
msg: Docker is required for E2E tests
cmds:
- go test ./internal/cli -run TestE2E -count=1 -timeout 5m -v {{.CLI_ARGS}}
lint:
desc: Run Go static checks; hold every workflow step to one task, and every install to pnpm through Socket Firewall
cmds:
- go vet ./cmd/... ./internal/...
- go tool taskgate
- go tool sfw check
spec:compliance:
desc: Verify spec compliance — schema attribute coverage + normative behavior (merge/lifecycle/discovery), excludes Go-only additions
cmds:
# All spec contracts are TestSpec* across packages: config attribute
# coverage + variable/discovery/devcontainerId behavior, imagemeta merge
# rules, lifecycle execution order.
- go test ./internal/... -run '^TestSpec' -count=1
spec:schema-update:
desc: Refresh the vendored devcontainer.json schema the spec-compliance gate checks against
cmds:
- curl -fsSL https://raw.githubusercontent.com/devcontainers/spec/main/schemas/devContainer.base.schema.json -o internal/config/testdata/devContainer.base.schema.json
- 'echo "Schema refreshed. Run ''task spec:compliance'' — any new spec property fails until modeled in internal/config/types.go."'
reference:
desc: Install and compile the TypeScript parity oracle
cmds:
- task: reference:install
- task: reference:compile
reference:commit:
desc: Record the reference commit the parity reports were made against (artifacts/reference-commit.txt)
cmds:
- mkdir -p artifacts
- git -C reference rev-parse HEAD > artifacts/reference-commit.txt
parity:contract:
desc: Run the hermetic CLI contract matrix
deps: [build, reference:compile]
cmds:
# Oracle flag-surface gate: every flag the TS oracle declares per command
# must be in the inventory (needs reference/ checked out, so it lives here).
- go test ./internal/cli -run TestOracleFlagCoverage -count=1
- PARITY_REPORT_FILE={{.ROOT_DIR}}/artifacts/parity-contract.json PARITY_STRICT=true PARITY_LANE=contract PARITY_SKIP_DOCKER=true PARITY_SKIP_NETWORK=true go test ./internal/cli -run TestParityMatrix -count=1 -timeout 10m -v {{.CLI_ARGS}}
parity:semantic:
desc: Run semantic cases without Docker or network
deps: [build, reference:compile]
cmds:
- PARITY_REPORT_FILE={{.ROOT_DIR}}/artifacts/parity-semantic.json PARITY_STRICT=true PARITY_LANE=semantic PARITY_SKIP_DOCKER=true PARITY_SKIP_NETWORK=true go test ./internal/cli -run TestParityMatrix -count=1 -timeout 10m -v {{.CLI_ARGS}}
parity:network:
desc: Run parity cases that require external network access
deps: [build, reference:compile]
cmds:
- PARITY_REPORT_FILE={{.ROOT_DIR}}/artifacts/parity-network.json PARITY_STRICT=true PARITY_LANE=all PARITY_NETWORK_ONLY=true PARITY_SKIP_DOCKER=true go test ./internal/cli -run TestParityMatrix -count=1 -timeout 15m -v {{.CLI_ARGS}}
parity:runtime:
desc: Run the runtime parity matrix with Docker (set PARITY_COMMAND=up,build to scope it)
deps: [build, reference:compile]
preconditions:
- sh: docker info >/dev/null 2>&1
msg: Docker is required for runtime parity
# Fail fast (~1s) if the containerd image store is off: the runtime lane's
# update-UID / cache-export builds run `docker build --platform` against
# locally-built images, which only the containerd store can resolve. Without
# it the TS oracle fails those builds while Go succeeds → false divergences.
# The test itself re-checks this (so a direct `go test` is covered too).
- sh: docker info 2>/dev/null | grep -q io.containerd.snapshotter
msg: |
The parity runtime lane requires the containerd image store (docker build
--platform against locally-built images). Enable it with `task ci:prepare-runner`,
or add {"features":{"containerd-snapshotter":true}} to /etc/docker/daemon.json.
# Fail fast (~1s) if the builder can't export build cache — otherwise the
# runtime cases (build.cache-to-*, build.output-oci-*) fail ~4 min deep with
# a cryptic buildkit error. The default `docker` driver lacks cache export.
- sh: go run ./cmd/devtool docker-cache-export
msg: |
The Docker builder cannot export build cache (needed by the runtime parity
matrix: build.cache-to-*, build.output-oci-*). Enable the containerd image
store (`devtool ci-prepare-runner`) or use a docker-container buildx builder.
cmds:
# -parallel default 2 for gate determinism: higher values overload the docker
# daemon on contended runners and transiently fail compose/heavy cases (they
# match in isolation). Raise via PARITY_PARALLEL on beefy/idle runners.
# PARITY_COMMAND (comma-separated, inherited from the environment) scopes the
# run to the cases affected by a change; unset/"all" runs the full matrix.
- PARITY_REPORT_FILE={{.ROOT_DIR}}/artifacts/parity-runtime.json PARITY_STRICT=true PARITY_LANE=all go test ./internal/cli -run TestParityMatrix -count=1 -parallel {{.PARITY_PARALLEL | default "2"}} -timeout 60m -v {{.CLI_ARGS}}
parity:plan:
desc: "Plan a runtime parity run (run=, commands= to $GITHUB_OUTPUT, or the terminal): on a push or PR the commands DIFF_BASE..DIFF_HEAD can affect, else the full matrix"
cmds:
- |
out="${GITHUB_OUTPUT:-/dev/stdout}"
case "${GITHUB_EVENT_NAME:-}" in
push|pull_request)
go run ./cmd/devtool parity-affected-commands "$DIFF_BASE" "$DIFF_HEAD" >> "$out"
;;
*)
# The daily run, which daily-changes has already let through.
echo "run=true" >> "$out"
echo "commands=all" >> "$out"
;;
esac
# A skipped lane - publish on a push or PR, or the whole matrix on a quiet day - is a pass.
parity:gate:
desc: "CI-ONLY: fail unless the runtime and publish parity jobs (RUNTIME_RESULT, PUBLISH_RESULT) succeeded or were skipped"
cmds:
- |
echo "runtime: $RUNTIME_RESULT, publish: $PUBLISH_RESULT"
for r in "$RUNTIME_RESULT" "$PUBLISH_RESULT"; do
case "$r" in
success|skipped) ;;
*) echo "runtime/publish parity failed"; exit 1 ;;
esac
done
parity:publish:
desc: Run the OCI publish parity (features publish/package) — network + registry heavy
deps: [build, reference:compile]
preconditions:
- sh: docker info >/dev/null 2>&1
msg: Docker is required for publish parity
cmds:
- go test ./internal/cli -run TestPublishParity -count=1 -timeout 10m -v
ci:prepare-runner:
desc: "CI-ONLY: free disk + enable the containerd image store + bake's fs entitlement off (uses sudo; not for local dev)"
cmds:
- go run ./cmd/devtool ci-prepare-runner
# The TS reference builds a compose service from a Dockerfile it writes under /tmp,
# outside the build context. Compose builds through bake, and bake on buildx 0.38
# (GitHub's runner image, 2026-10-24) refuses to read a file outside the context unless
# the caller passes --allow=fs.read, which the reference never does: every compose case
# failed with "additional privileges requested" while the Go CLI, which passes the
# entitlement itself, succeeded. Off for the whole job, so both sides build the same way.
- echo "BUILDX_BAKE_ENTITLEMENTS_FS=0" >> "${GITHUB_ENV:-/dev/null}"
ci:daily-changes:
desc: "Whether the daily lanes run (run= to $GITHUB_OUTPUT, or the terminal): always on a manual dispatch, else only when HEAD changed in the last day"
cmds:
- |
out="${GITHUB_OUTPUT:-/dev/stdout}"
if [ "${GITHUB_EVENT_NAME:-}" = "workflow_dispatch" ]; then
echo "manual dispatch → always run" >&2
echo "run=true" >> "$out"
else
go run ./cmd/devtool daily-changed >> "$out"
fi
clean:
desc: Remove generated artifacts and test containers
cmds:
- task: docker:clean
- rm -f {{.BINARY}} coverage.out coverage.html
- rm -rf artifacts dist src/test/tmp
release:
desc: Run GoReleaser; pass --snapshot for a local release
preconditions:
- sh: command -v goreleaser >/dev/null
msg: goreleaser is required
cmds:
- goreleaser release --clean {{.CLI_ARGS}}
# The release workflow's own steps. What only the workflow knows - the dispatch input, the
# prepare job's outputs, the published digest - comes in the environment.
release:resolve:
desc: "Resolve the version, tag and commit to release (to $GITHUB_OUTPUT, or the terminal): a dispatch's VERSION_INPUT or the next CalVer, else the pushed tag"
cmds:
# Outside CI there is no tag push, so it answers as a dispatch would: a dry run.
- |
set -euo pipefail
if [ "${GITHUB_EVENT_NAME:-workflow_dispatch}" = "workflow_dispatch" ]; then
VERSION="${VERSION_INPUT:-}"
VERSION="${VERSION#v}"
if [ -z "$VERSION" ]; then
# Auto CalVer: YYYYMMDD.<next build number for today>.
DATE="$(date -u +%Y%m%d)"
N="$(git tag -l "v${DATE}.*" | wc -l | tr -d ' ')"
VERSION="${DATE}.$(printf '%02d' "$((N + 1))")"
fi
# Reject anything that is not a plain version token (defense in depth:
# this value later reaches git/docker command lines).
case "$VERSION" in
*[!0-9A-Za-z.+-]*|"")
echo "::error::invalid version '${VERSION}': use only [0-9A-Za-z.+-]"; exit 1 ;;
esac
TAG="v${VERSION}"
if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null 2>&1; then
echo "::error::tag ${TAG} already exists — releases are immutable, bump the build number"
exit 1
fi
SHA="$(git rev-parse HEAD)"
else
TAG="${GITHUB_REF_NAME}"
VERSION="${TAG#v}"
SHA="${GITHUB_SHA}"
fi
out="${GITHUB_OUTPUT:-/dev/stdout}"
echo "version=${VERSION}" >>"$out"
echo "tag=${TAG}" >>"$out"
echo "sha=${SHA}" >>"$out"
echo "Releasing ${TAG} (version ${VERSION}) from ${SHA}" >>"${GITHUB_STEP_SUMMARY:-/dev/stdout}"
release:tag:
desc: "CI-ONLY: create and push the annotated tag RELEASE_TAG for RELEASE_VERSION, as github-actions[bot]"
cmds:
- |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git tag -a "$RELEASE_TAG" -m "Release $RELEASE_VERSION"
git push origin "$RELEASE_TAG"
release:smoke:
desc: Check the published image of RELEASE_VERSION reports that version
cmds:
- |
set -euo pipefail
IMAGE="{{.IMAGE}}:$RELEASE_VERSION"
GOT="$(docker run --rm "$IMAGE" --version)"
echo "image reported version: $GOT (expected: $RELEASE_VERSION)"
test "$GOT" = "$RELEASE_VERSION"
release:digest:
desc: "Record the digest of the published image of RELEASE_VERSION (digest= to $GITHUB_OUTPUT, and the job summary)"
cmds:
- |
set -euo pipefail
IMAGE="{{.IMAGE}}:$RELEASE_VERSION"
DIGEST="$(docker buildx imagetools inspect "$IMAGE" --format '{{`{{ json .Manifest.Digest }}`}}' | tr -d '\"')"
echo "digest=${DIGEST}" >>"${GITHUB_OUTPUT:-/dev/stdout}"
{
echo "## Published image"
echo ""
echo "- \`${IMAGE}\`"
echo "- digest: \`${DIGEST}\`"
} >>"${GITHUB_STEP_SUMMARY:-/dev/stdout}"
docker buildx imagetools inspect "$IMAGE"
release:latest:
desc: "CI-ONLY: move the image's mutable :latest tag to RELEASE_DIGEST"
cmds:
- |
set -euo pipefail
docker buildx imagetools create \
-t {{.IMAGE}}:latest \
"{{.IMAGE}}@${RELEASE_DIGEST}"
# Against the immutable digest, not the mutable tag. cosign/syft rather than buildx's own
# --provenance/--sbom, which would break docker_manifests.
release:sign:
desc: "CI-ONLY: sign the image at RELEASE_DIGEST and attest its SBOM (cosign keyless)"
cmds:
- |
set -euo pipefail
REF="{{.IMAGE}}@${RELEASE_DIGEST}"
cosign sign --yes "$REF"
syft "$REF" -o spdx-json=image.sbom.spdx.json
cosign attest --yes --predicate image.sbom.spdx.json --type spdxjson "$REF"
metrics:
desc: "Perf/distribution metrics (non-gating): Go startup vs Node oracle + artifact sizes -> artifacts/metrics.json"
deps: [build]
vars:
ORACLE: reference/dist/spec-node/devContainersSpecCLI.js
cmds:
# Non-gating: never fail the caller. All measurement is best-effort.
- cmd: |
set -u
out={{.ROOT_DIR}}/artifacts/metrics.json
mkdir -p {{.ROOT_DIR}}/artifacts
bin={{.ROOT_DIR}}/{{.BINARY}}
oracle={{.ROOT_DIR}}/{{.ORACLE}}
runs="${METRICS_RUNS:-30}"
# Portable byte size of a file (Linux stat -c; BSD/mac stat -f fallback).
fsize() { stat -c%s "$1" 2>/dev/null || stat -f%z "$1" 2>/dev/null || echo null; }
# Mean wall-clock ms for a command over N runs.
# Uses hyperfine when present (JSON mean, seconds->ms); otherwise a
# portable `date +%s%N` loop averaged over the runs.
have_hyperfine=0
command -v hyperfine >/dev/null 2>&1 && have_hyperfine=1
time_cmd() {
# args: label, then the command
_label="$1"; shift
if [ "$have_hyperfine" = "1" ]; then
_hf=$(hyperfine --warmup 3 --runs "$runs" --shell=none --export-json /dev/stdout "$*" 2>/dev/null \
| node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{try{const j=JSON.parse(d);process.stdout.write(String(j.results[0].mean*1000))}catch(e){process.stdout.write("null")}})' 2>/dev/null)
[ -n "$_hf" ] && echo "$_hf" || echo null
else
# warmup
"$@" >/dev/null 2>&1 || true
_start=$(date +%s%N)
_i=0
while [ "$_i" -lt "$runs" ]; do "$@" >/dev/null 2>&1 || true; _i=$((_i+1)); done
_end=$(date +%s%N)
# (end-start) ns / runs / 1e6 -> ms mean
awk -v s="$_start" -v e="$_end" -v n="$runs" 'BEGIN{printf "%.3f",(e-s)/n/1000000}'
fi
}
echo "metrics: timing tool = $([ "$have_hyperfine" = 1 ] && echo hyperfine || echo date-loop), runs=$runs"
go_version_ms=$(time_cmd go_version "$bin" --version)
# read-configuration no-op against a minimal image-based workspace
# (variable substitution only; no Docker involved).
ws=$(mktemp -d)
mkdir -p "$ws/.devcontainer"
printf '{"image":"mcr.microsoft.com/devcontainers/base:ubuntu"}\n' > "$ws/.devcontainer/devcontainer.json"
go_readcfg_ms=$(time_cmd go_readcfg "$bin" read-configuration --workspace-folder "$ws")
node_version_ms=null
if [ -f "$oracle" ] && command -v node >/dev/null 2>&1; then
node_version_ms=$(time_cmd node_version node "$oracle" --version)
else
echo "metrics: node oracle not found at $oracle (run 'task reference') — skipping Node A/B"
fi
rm -rf "$ws"
# Sizes: local binary + cross-compiled dist binaries + gzip-compressed size.
bin_size=$(fsize "$bin")
la_bin=null; la_gz=null; lr_bin=null; lr_gz=null
if [ -f {{.ROOT_DIR}}/dist/{{.BINARY}}-linux-amd64 ]; then
la_bin=$(fsize {{.ROOT_DIR}}/dist/{{.BINARY}}-linux-amd64)
la_gz=$(gzip -c {{.ROOT_DIR}}/dist/{{.BINARY}}-linux-amd64 | wc -c | tr -d ' ')
fi
if [ -f {{.ROOT_DIR}}/dist/{{.BINARY}}-linux-arm64 ]; then
lr_bin=$(fsize {{.ROOT_DIR}}/dist/{{.BINARY}}-linux-arm64)
lr_gz=$(gzip -c {{.ROOT_DIR}}/dist/{{.BINARY}}-linux-arm64 | wc -c | tr -d ' ')
fi
[ "$la_bin" = null ] && echo "metrics: dist binaries absent — run 'task build:cross' for release sizes"
cat > "$out" <<EOF
{
"version": "{{.VERSION}}",
"generated_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"timing_tool": "$([ "$have_hyperfine" = 1 ] && echo hyperfine || echo date-loop)",
"runs": $runs,
"startup_ms": {
"go_version": $go_version_ms,
"go_read_configuration": $go_readcfg_ms,
"node_version": $node_version_ms
},
"sizes_bytes": {
"local_binary": $bin_size,
"linux_amd64_binary": $la_bin,
"linux_amd64_gzip": $la_gz,
"linux_arm64_binary": $lr_bin,
"linux_arm64_gzip": $lr_gz
}
}
EOF
echo "metrics: wrote $out"
cat "$out"
ignore_error: true
# reference/ is upstream's submodule, pinned by upstream's yarn.lock. pnpm installs it, through
# the firewall: `pnpm import` reads yarn.lock into a pnpm-lock.yaml at the same versions, and
# the frozen install holds to it. Neither file pnpm needs can be committed to a submodule, so
# both are made here, and stay beside node_modules as files the submodule does not track.
reference:install:
internal: true
dir: reference
status: [test -d node_modules]
cmds:
# allowBuilds: the two install scripts yarn ran. esbuild's checks its platform binary, and
# node-pty, which the oracle loads to exec with a TTY, is compiled; pnpm runs no other.
# verifyDepsBeforeRun: `pnpm run` would otherwise install what it finds out of date itself,
# around the firewall.
- |
cat > pnpm-workspace.yaml <<'EOF'
allowBuilds:
esbuild: true
node-pty: true
verifyDepsBeforeRun: error
EOF
- go tool sfw pnpm import
- go tool sfw pnpm install --frozen-lockfile
reference:compile:
internal: true
deps: [reference:install]
dir: reference
sources: [src/**/*.ts, package.json, yarn.lock]
generates: [dist/spec-node/devContainersSpecCLI.js]
cmds:
- pnpm run compile
docker:clean:
internal: true
cmds:
- docker ps -q --filter "label=devcontainer.local_folder" | xargs -r docker rm -f 2>/dev/null || true
- docker ps -aq --filter "label=com.docker.compose.project" | xargs -r docker rm -f 2>/dev/null || true
silent: true