diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..0b3443f --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,149 @@ +# Gitea Actions release workflow for ctask. +# +# Triggered by pushing a tag matching v*. Builds release artifacts using the +# same scripts/build-release.sh that operators run locally, then creates a +# Gitea release via the API and uploads the four artifacts. +# +# Design notes: +# - Pure shell + Gitea API. No actions/checkout, no actions/setup-go, no +# softprops/action-gh-release or any third-party action. +# - Tag is parsed from `gitea.ref` (strip `refs/tags/` prefix). The plan +# pins this explicitly: do not depend on `gitea.ref_name`. +# - RC tags (matching `-rc`) are deletable: if a release for the tag +# already exists, it's deleted and recreated. Final tags are +# immutable — the workflow refuses to overwrite an existing release. +# - RC tags are marked `prerelease: true` on the Gitea release page. +# - Runner label `ctask-release` is provisioned by the shared +# vps-act-runner-01 host, backed by golang:1.26-bookworm. jq is not +# in that base image so it's installed at the top of the job. +name: Release + +on: + push: + tags: + - 'v*' + +jobs: + release: + runs-on: ctask-release + env: + TOKEN: ${{ secrets.RELEASE_TOKEN }} + API: https://git.typebased.dev/api/v1 + REPO: typebasedio/ctask + steps: + - name: Install jq + run: | + set -euo pipefail + if ! command -v jq >/dev/null 2>&1; then + apt-get update + apt-get install -y --no-install-recommends jq + fi + jq --version + + - name: Derive tag from gitea.ref + run: | + set -euo pipefail + TAG="${{ gitea.ref }}" + TAG="${TAG#refs/tags/}" + echo "TAG=${TAG}" >> "$GITHUB_ENV" + echo "Building release for tag: ${TAG}" + + - name: Checkout source at tag + run: | + set -euo pipefail + git clone --depth 1 --branch "${TAG}" \ + https://git.typebased.dev/typebasedio/ctask.git . + git rev-parse HEAD + + - name: Run tests + run: | + set -euo pipefail + go test ./... + go vet ./... + + - name: Build artifacts + run: bash scripts/build-release.sh "${TAG}" + + - name: Create release and upload artifacts + run: | + set -euo pipefail + + # If a release already exists for this tag, decide what to do: + # RC tags (anything containing '-rc') are deletable and + # recreatable. Final tags are immutable. + EXISTING=$(curl -s -o /dev/null -w "%{http_code}" \ + -H "Authorization: token ${TOKEN}" \ + "${API}/repos/${REPO}/releases/tags/${TAG}") + + if [ "${EXISTING}" = "200" ]; then + if echo "${TAG}" | grep -q -- '-rc'; then + EXISTING_ID=$(curl -sfS \ + -H "Authorization: token ${TOKEN}" \ + "${API}/repos/${REPO}/releases/tags/${TAG}" | jq -r '.id') + curl -sfS -X DELETE \ + -H "Authorization: token ${TOKEN}" \ + "${API}/repos/${REPO}/releases/${EXISTING_ID}" + echo "Deleted existing RC release ${EXISTING_ID}" + else + echo "ERROR: Release ${TAG} already exists and is not an RC. Refusing to overwrite." >&2 + exit 1 + fi + fi + + # Mark RC tags as prereleases on the Gitea release page. + if echo "${TAG}" | grep -q -- '-rc'; then + PRERELEASE=true + else + PRERELEASE=false + fi + + BODY=$(jq -nc \ + --arg tag "${TAG}" \ + --argjson pre "${PRERELEASE}" \ + '{tag_name:$tag, name:$tag, draft:false, prerelease:$pre}') + + RELEASE_ID=$(curl -sfS -X POST \ + -H "Authorization: token ${TOKEN}" \ + -H "Content-Type: application/json" \ + -d "${BODY}" \ + "${API}/repos/${REPO}/releases" | jq -r '.id') + + if [ -z "${RELEASE_ID}" ] || [ "${RELEASE_ID}" = "null" ]; then + echo "ERROR: failed to create release for ${TAG}" >&2 + exit 1 + fi + echo "Created release ${RELEASE_ID} for ${TAG}" + + for file in \ + dist/ctask-linux-amd64 \ + dist/ctask-windows-amd64.exe \ + dist/checksums-sha256.txt \ + dist/release-manifest.json; do + filename=$(basename "${file}") + echo "Uploading ${filename}..." + curl -sfS -X POST \ + -H "Authorization: token ${TOKEN}" \ + -H "Content-Type: application/octet-stream" \ + --data-binary "@${file}" \ + "${API}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}" \ + > /dev/null + done + echo "Uploaded 4 assets to release ${RELEASE_ID}" + + - name: Verify published artifacts + run: | + set -euo pipefail + BASE="https://git.typebased.dev/typebasedio/ctask/releases/download" + rm -rf /tmp/verify + mkdir -p /tmp/verify + cd /tmp/verify + curl -fL "${BASE}/${TAG}/ctask-linux-amd64" -o ctask-linux-amd64 + curl -fL "${BASE}/${TAG}/checksums-sha256.txt" -o checksums-sha256.txt + curl -fL "${BASE}/${TAG}/release-manifest.json" -o release-manifest.json + sha256sum -c checksums-sha256.txt --ignore-missing + jq -e . release-manifest.json > /dev/null + chmod +x ctask-linux-amd64 + REPORTED="$(./ctask-linux-amd64 --version)" + echo "Published binary reports: ${REPORTED}" + echo "${REPORTED}" | grep -qF "${TAG}" + echo "Download verification passed for ${TAG}" diff --git a/cmd/root.go b/cmd/root.go index 6b5c71b..74f71d6 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -6,7 +6,17 @@ import ( "github.com/spf13/cobra" ) -var version = "0.6.0" +// version and commit hold the values injected at release build time +// (via `-ldflags -X main.version=... -X main.commit=...` propagated through +// SetVersionInfo) or the defaults below for local development builds. +// +// `version` is the release tag verbatim (e.g. "v0.6.1-rc.1"). For dev +// builds it is the literal "dev". `commit` is the full git SHA at build +// time, or empty when not injected. +var ( + version = "dev" + commit = "" +) var rootCmd = &cobra.Command{ Use: "ctask", @@ -18,7 +28,40 @@ func Execute() error { return rootCmd.Execute() } -func init() { - rootCmd.Version = version - rootCmd.SetVersionTemplate(fmt.Sprintf("ctask v%s\n", version)) +// SetVersionInfo is called from main() before Execute() to forward the +// build-time-injected version and commit into the cmd package. Pass empty +// strings to keep the package defaults. +func SetVersionInfo(v, c string) { + if v != "" { + version = v + } + if c != "" { + commit = c + } + applyVersionTemplate() +} + +// applyVersionTemplate rebuilds Cobra's --version output from the current +// version/commit values. Format: +// +// ctask v0.6.1-rc.1 (abcdef1) // tagged build, commit known +// ctask v0.6.1-rc.1 // tagged build, no commit injected +// ctask dev // local build +func applyVersionTemplate() { + rootCmd.Version = version + var line string + if commit != "" { + short := commit + if len(short) > 7 { + short = short[:7] + } + line = fmt.Sprintf("ctask %s (%s)\n", version, short) + } else { + line = fmt.Sprintf("ctask %s\n", version) + } + rootCmd.SetVersionTemplate(line) +} + +func init() { + applyVersionTemplate() } diff --git a/main.go b/main.go index 61bad87..d212ad9 100644 --- a/main.go +++ b/main.go @@ -7,7 +7,20 @@ import ( "github.com/warrenronsiek/ctask/cmd" ) +// version and commit are populated at release build time via: +// +// go build -ldflags "-X main.version=vX.Y.Z -X main.commit=" +// +// Defaults below apply when the binary is built without ldflags (e.g. +// `go build`, `just install`) so local development still produces a +// runnable binary whose `--version` output is unambiguous. +var ( + version = "dev" + commit = "" +) + func main() { + cmd.SetVersionInfo(version, commit) if err := cmd.Execute(); err != nil { msg := err.Error() // Exit code 127 for agent command not found (per spec) @@ -21,4 +34,3 @@ func main() { os.Exit(1) } } - diff --git a/notes.md b/notes.md index 59d421a..97d8998 100644 --- a/notes.md +++ b/notes.md @@ -727,3 +727,112 @@ For the v0.4 surface: - **v0.5.4:** Do not move the Attach-hint string construction into `session.SessionStatus`. The cmd layer owns invocation-name rendering; the session package owns lease parsing. The boundary is what keeps SessionStatus testable without a `withInvocationName` seam. - **v0.5.4:** Do not re-introduce a hardcoded `"ctask"` in command-form hints. Use `invocationName()`. The regression tests pin a non-canonical name specifically to catch this. +--- + +# Release-Publishing Pipeline (plan: `ctask-release-pipeline-plan-final.md`) + +## Phase 0 — Runner & Release API Preflight — **BLOCKED** (started 2026-05-19) + +Tooling versions: +- Workstation: go 1.26.3, git 2.53.0, curl 8.18.0, sha256sum (coreutils 8.32), jq 1.8.1, bash 5.2.37 +- VPS host (`netcup`): git 2.47.3, curl 8.14.1, sha256sum (coreutils 9.7), jq 1.7; **no Go on host** + +Gitea instance: +- Gitea 1.25.5 in Docker container `gitea` (`gitea/gitea:1.25.5`), fronted by Traefik. +- API `https://git.typebased.dev/api/v1` reachable (200) from workstation and VPS. +- No explicit `[actions]` section in app.ini -> Actions enabled by default (1.21+). +- Org `typebasedio` -> 404 unauthenticated; repo `typebasedio/ctask` -> 404 (not created). + +BLOCKERS (Phase 0 gate NOT satisfied): +1. **No Gitea Actions runner exists** anywhere on the VPS — no `act_runner` + container/binary/process/service. Phases 3-4 cannot run without a registered, + labelled runner. Plan assumed a runner already existed. +2. **Token value not located.** `workstation-dev-environment` token value needed + for release API preflight + `RELEASE_TOKEN` secret. Awaiting user retrieval + instructions (must not create a second token). + +Open issues: runner install/registration decision; token retrieval source. + +### Update 2026-05-20 — runner provisioned + +Shared Typebased runner `vps-act-runner-01` came online (provisioned by the +windows-dev agent; canonical docs in `typebasedio/windows-dev-setup` @ ae9eef5, +`typebasedio/project-registry` @ 2ddc8c6). Contract for ctask: + +- `runs-on: ctask-release` +- Backed by `golang:1.26-bookworm` (jq NOT in base image — workflow installs it) +- Pure Go workflow only; no Docker-in-job, no Docker image build steps +- `RELEASE_TOKEN` repo/user secret used for release create/upload +- Prior runner-registration token leak rotated; current credential is fresh + +Phase 0 runner blocker resolved. Token still pending (Bitwarden locked at last +check; user will unlock and we'll fetch by item name). + +## Phase 2 — Local release build target — **DONE** (2026-05-20) + +Added: + +- `scripts/build-release.sh` — single recipe shared with CI; wipes `dist/`, + cross-compiles `ctask-linux-amd64` and `ctask-windows-amd64.exe` with + `CGO_ENABLED=0` and ldflags injecting `main.version` and `main.commit`, + writes `checksums-sha256.txt` and `release-manifest.json`. +- `scripts/release-check.sh` — runs `go test ./...`, `go vet ./...`, + invokes `build-release.sh`, then asserts the binary's `--version` + contains the requested tag. Falls back to the Windows binary on + Windows hosts where the Linux binary can't exec. + +## Phase 5 prerequisite — version injection — **DONE** (2026-05-20) + +- `main.go` declares `var version = "dev"` and `var commit = ""` and forwards + them to `cmd.SetVersionInfo` before `cmd.Execute()`. +- `cmd/root.go` exposes `SetVersionInfo(v, c)` and rebuilds the Cobra version + template via `applyVersionTemplate()`. +- Output format (single line, decided pre-Phase 5): + - `ctask v0.6.1-rc.1 (0e8e4a5)` — tagged build, commit known + - `ctask v0.6.1-rc.1` — tagged build, no commit injected + - `ctask dev` — local build (no ldflags) +- Phase 5 Dockerfile exact-equality assertion (optional in the plan) will need + to be a substring/prefix check because the commit is appended. Sample: + `./ctask --version | grep -qF "${CTASK_VERSION}"`. +- `just install` / local Windows workflow unchanged — local builds simply now + print `ctask dev` instead of `ctask v0.6.0`. No behavioral regression. + +### Local validation result (commit 0e8e4a5) + +`bash scripts/release-check.sh v0.6.1-rc.1`: + +- tests: all packages pass (`cmd`, `internal/agent`, `internal/config`, + `internal/lockfile`, `internal/seed`, `internal/session`, `internal/shell`, + `internal/workspace`) +- vet: clean +- artifacts: + - `dist/ctask-linux-amd64` sha256 `808c71f982a3ed50f63bd5c4e1d25c4cf0643c887b8c2e011c5181a9020d1004` (5,288,098 bytes) + - `dist/ctask-windows-amd64.exe` sha256 `c8dee43d5ade90899020fb8b31a41230672057b74478aa78f91d6f509dd689e8` (5,511,168 bytes) + - `dist/checksums-sha256.txt` (`sha256sum -c` passes) + - `dist/release-manifest.json` (valid JSON; commit `0e8e4a5d7bc4320cd933008d5b6e505f2b3c5ec4`) +- `--version` output on Windows host: `ctask v0.6.1-rc.1 (0e8e4a5)` +- Linux binary cross-exec'd on VPS (`netcup`): `ctask v0.6.1-rc.1 (0e8e4a5)` ✓ + +## Phase 3 — Gitea Actions workflow — **DRAFTED** (2026-05-20) + +Added `.gitea/workflows/release.yml`. Not yet committed/pushed. + +- Trigger: `push` of tags matching `v*` +- `runs-on: ctask-release` +- Tag parsed from `gitea.ref` (strips `refs/tags/`), NOT `gitea.ref_name` +- Installs jq at job start (`golang:1.26-bookworm` base lacks it) +- Steps: derive tag → `git clone --depth 1 --branch` (no `actions/checkout`) + → `go test ./...` + `go vet ./...` → `bash scripts/build-release.sh` + → create release via Gitea API (delete+recreate if RC and exists; refuse if + non-RC and exists) → upload 4 assets → download-verify with `sha256sum -c` + and `--version` substring match. +- All JSON bodies built with `jq -nc` rather than string-interpolated to + prevent quoting bugs. + +## Still pending (token-dependent) + +- Phase 0 token check: create+delete a draft release against the live repo. +- Phase 1: create `typebasedio/ctask` (public), wire `origin` remote, push `main`. +- Phase 3 secret: set `RELEASE_TOKEN` from the existing `workstation-dev-environment` token (no new PAT). +- Phase 4: commit changes, push, tag `v0.6.1-rc.1`, validate the CI run. + diff --git a/scripts/build-release.sh b/scripts/build-release.sh new file mode 100644 index 0000000..7f435ff --- /dev/null +++ b/scripts/build-release.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# build-release.sh — produce the ctask release artifact set under dist/. +# +# This is the single recipe shared between local validation and CI: the +# Gitea Actions workflow invokes this same script. Running it locally +# produces byte-identical artifacts (given the same Go toolchain version +# and commit) so the build is locally verifiable before tagging. +# +# Usage: +# bash scripts/build-release.sh +# +# is the release tag verbatim, e.g. "v0.6.1-rc.1". It is +# injected into the binary via -ldflags -X main.version. The current +# git HEAD is injected as main.commit. +# +# Artifacts produced: +# dist/ctask-linux-amd64 +# dist/ctask-windows-amd64.exe +# dist/checksums-sha256.txt +# dist/release-manifest.json +set -euo pipefail + +VERSION="${1:?Usage: build-release.sh }" +COMMIT="$(git rev-parse HEAD)" + +# Wipe dist/ to guarantee no stale artifacts from earlier builds leak +# into the manifest or checksum file. +rm -rf dist +mkdir -p dist + +# CGO_ENABLED=0 forces a pure-Go static link so the Linux binary runs in +# minimal containers (alpine, distroless, scratch). -s -w strip symbols +# and DWARF for a smaller artifact. +LDFLAGS="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}" + +CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o dist/ctask-linux-amd64 ./ +CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "${LDFLAGS}" -o dist/ctask-windows-amd64.exe ./ + +cd dist +# Deterministic ordering so a diff between local and CI checksums is +# easy to read. +sha256sum ctask-linux-amd64 ctask-windows-amd64.exe > checksums-sha256.txt + +LINUX_SHA=$(awk '/ctask-linux-amd64$/{print $1}' checksums-sha256.txt) +WINDOWS_SHA=$(awk '/ctask-windows-amd64\.exe$/{print $1}' checksums-sha256.txt) + +cat > release-manifest.json < +# +# Example: +# bash scripts/release-check.sh v0.6.1-rc.1 +set -euo pipefail + +VERSION="${1:?Usage: release-check.sh }" + +echo "=== Running tests ===" +go test ./... + +echo "" +echo "=== Running go vet ===" +go vet ./... + +echo "" +echo "=== Building release artifacts ===" +bash "$(dirname "$0")/build-release.sh" "${VERSION}" + +echo "" +echo "=== Verifying version output ===" +REPORTED="$(./dist/ctask-linux-amd64 --version 2>/dev/null || true)" +if [ -z "${REPORTED}" ]; then + # Linux binary won't run natively on Windows — fall back to the + # Windows artifact so this script remains useful on the workstation. + REPORTED="$(./dist/ctask-windows-amd64.exe --version)" + echo "Binary reports (windows-amd64): ${REPORTED}" +else + echo "Binary reports (linux-amd64): ${REPORTED}" +fi + +# Soft-assert the version is present in the reported string. The CI +# workflow's Verify-download step performs the same check. +if ! echo "${REPORTED}" | grep -qF "${VERSION}"; then + echo "ERROR: --version output does not contain ${VERSION}" >&2 + exit 1 +fi + +echo "" +echo "release-check OK for ${VERSION}"