Compare commits

1 Commits

Author SHA1 Message Date
typebasedio bbc41646ee feat(release): add Gitea release-publishing pipeline
Release / release (push) Successful in 27s
- Add scripts/build-release.sh: cross-compile linux+windows amd64 with
  ldflags injecting main.version and main.commit, write
  checksums-sha256.txt and release-manifest.json (full commit SHA).
- Add scripts/release-check.sh: local mirror of CI (test, vet, build,
  --version substring check); falls back to Windows artifact when run
  on a Windows host where the Linux binary can't exec.
- Wire main.version / main.commit -> cmd.SetVersionInfo. Default to
  "dev" / "" so local builds without ldflags still produce a
  sensible string. Output format: single line
  'ctask <version> (<short-sha>)' or 'ctask <version>' / 'ctask dev'.
- Add .gitea/workflows/release.yml: triggered on v* tags, runs-on
  ctask-release (golang:1.26-bookworm). Tag parsed from gitea.ref
  (not gitea.ref_name). Pure shell + Gitea API; no actions/checkout,
  no setup-go, no third-party release action. Installs jq at job
  start. RC tags are deletable+recreatable; final tags are immutable.
  Verify step downloads published assets, sha256sum -c's, and runs
  --version.
- notes.md: log Phase 0/2/3 + version-injection completion.
2026-05-20 15:19:59 -04:00
6 changed files with 440 additions and 5 deletions
+149
View File
@@ -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}"
+47 -4
View File
@@ -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()
}
+13 -1
View File
@@ -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=<git-sha>"
//
// 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)
}
}
+109
View File
@@ -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.
+73
View File
@@ -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 <version>
#
# <version> 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 <version>}"
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 <<MANIFEST
{
"project": "ctask",
"version": "${VERSION}",
"commit": "${COMMIT}",
"artifacts": [
{
"name": "ctask-linux-amd64",
"os": "linux",
"arch": "amd64",
"sha256": "${LINUX_SHA}"
},
{
"name": "ctask-windows-amd64.exe",
"os": "windows",
"arch": "amd64",
"sha256": "${WINDOWS_SHA}"
}
]
}
MANIFEST
echo "Build complete:"
cat checksums-sha256.txt
echo ""
cat release-manifest.json
echo ""
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
# release-check.sh — local mirror of the CI release pipeline.
#
# Runs the same gates the Gitea Actions workflow runs (tests, vet, build)
# so a release is locally validated before any tag is pushed. The final
# step prints the binary's --version so the operator can confirm the
# build-time-injected version string matches the tag.
#
# Usage:
# bash scripts/release-check.sh <version>
#
# Example:
# bash scripts/release-check.sh v0.6.1-rc.1
set -euo pipefail
VERSION="${1:?Usage: release-check.sh <version>}"
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}"