diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 4bb6304..a66f6cd 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -108,12 +108,22 @@ jobs: # another platform is never compiled by it. The session lock is the # first such seam, and a Windows-only file that stops compiling would # otherwise reach a release unnoticed. + # + # This list is every target .goreleaser.yaml releases, and the two must + # stay equal: a target that is released without being compiled here can + # break in a tag rather than in the pull request that broke it. if: needs.changes.outputs.shell == 'true' shell: bash + # ARMv6 is the variant the release builds, and Go defaults to ARMv7. The + # check compiles what ships rather than a variant nobody receives; the + # setting is inert for every other target in the list. + env: + GOARM: '6' run: | set -euo pipefail - for target in windows/amd64 darwin/amd64 darwin/arm64; do + for target in linux/amd64 linux/arm64 linux/arm linux/386 \ + darwin/amd64 darwin/arm64 windows/amd64 windows/arm64; do echo "Building for ${target}" GOOS="${target%%/*}" GOARCH="${target##*/}" go build ./... GOOS="${target%%/*}" GOARCH="${target##*/}" go vet ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..7f08bc6 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,165 @@ +name: Release + +# A version tag is the whole trigger. Nothing publishes from a branch, so an +# artifact on the release page always corresponds to a tag someone pushed. +on: + push: + tags: + - 'v*' + +# Read-only by default. Only the job that publishes the release is granted write +# access to repository contents, and nothing here is granted anything else. +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + # A tag may not ship artifacts that fail the proof. This is the same gate a + # pull request runs and the same script a contributor runs locally, so the + # release is held to the standard the repository already enforces rather than + # to a second, weaker one. + acceptance: + name: Architecture Proof + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version: '1.25.x' + cache-dependency-path: | + go.mod + sdk/go.mod + + - name: Run the architecture-proof acceptance gate + run: ./scripts/acceptance.sh + + release: + name: Publish artifacts + needs: acceptance + runs-on: ubuntu-latest + permissions: + # Creating the release and uploading its assets. GoReleaser needs nothing + # further: this release publishes no container image, no Homebrew tap, and + # no package repository. + contents: write + steps: + - name: Check out repository + uses: actions/checkout@v7 + with: + # GoReleaser reads the tag history to determine the version and to + # build the changelog, and a shallow clone leaves it with neither. + fetch-depth: 0 + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version: '1.25.x' + cache-dependency-path: | + go.mod + sdk/go.mod + + - name: Build and publish the release + uses: goreleaser/goreleaser-action@v6 + with: + # Pinned in step with the Makefile's own pin, so a contributor running + # `make release-snapshot` builds with the version that publishes. + version: v2.17.1 + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Proves what a user will actually find, by fetching it back from the + # release page rather than inspecting the build output: the checksum file + # describes every archive published, the archive named for this tag + # extracts to a binary reporting this tag's version, and the protocol + # version it claims is the one the shell's own source defaults to. + # + # A release that ships a binary still reporting the development + # placeholder, or a checksum file that does not cover every archive + # beside it, fails here rather than reaching a user. + - name: Verify the published artifacts + shell: bash + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + + tag="${GITHUB_REF_NAME}" + archive="wso2-cli-${tag}-linux-amd64.tar.gz" + + published="$(mktemp -d)" + gh release download "${tag}" --dir "${published}" --repo "${GITHUB_REPOSITORY}" + + cd "${published}" + + # What the install scripts will trust has to be what was built. A + # checksum file rewritten or truncated between build and publish is + # exactly what this comparison catches. + if ! diff -u "${GITHUB_WORKSPACE}/dist/checksums.txt" checksums.txt; then + echo "::error::the published checksums.txt is not the one that was built" + exit 1 + fi + + # --ignore-missing exits non-zero when it verifies nothing, so this + # cannot pass on an empty file. What it cannot see is an archive that + # is present but unlisted, so the two sets of names are compared as + # well: a target dropped from the checksum file would otherwise ship + # unverifiable. Names rather than counts, because a duplicated line + # keeps the count right while leaving another archive uncovered. + sha256sum --check --ignore-missing checksums.txt + find . -maxdepth 1 -type f \( -name '*.tar.gz' -o -name '*.zip' \) \ + -printf '%f\n' | sort >published-names.txt + awk '{ name = $2; sub(/^\*/, "", name); print name }' checksums.txt | sort >listed-names.txt + if ! diff -u published-names.txt listed-names.txt; then + echo "::error::the published archives and the checksum file do not describe the same set" + exit 1 + fi + echo "$(wc -l /dev/null || \ SMOKE_PACKAGE := ./test/smoke/ +# GoReleaser builds the release artifacts. It is pinned and run through `go run` +# rather than installed, so a contributor reproducing a release uses the same +# version CI does without adding a tool to their machine. The same version is +# pinned in .github/workflows/release.yml; move both together. +GORELEASER ?= $(GO) run github.com/goreleaser/goreleaser/v2@v2.17.1 + # A file describing one deployment, sourced by the live targets when it exists. # # Go has no dotenv convention and this module stays lean, so nothing parses this @@ -82,6 +88,8 @@ help: @echo ' make lint Lint the shell, including the build-tagged live runs.' @echo ' make acceptance Run the full architecture-proof acceptance gate.' @echo ' make smoke-build Compile the live runs without executing them.' + @echo ' make release-check Validate the release configuration.' + @echo ' make release-snapshot Build every release artifact into dist/, publishing nothing.' @echo '' @echo 'Against a real deployment (Asgardeo, Identity Server 7.x, or ThunderID):' @echo ' make smoke-login Log in and broker one acquisition. Opens a browser.' @@ -116,6 +124,20 @@ lint: acceptance: ./scripts/acceptance.sh +# Builds every release artifact into dist/ and publishes nothing. This is how a +# contributor checks a change to .goreleaser.yaml, and how the artifact names and +# checksums can be inspected without pushing a tag. A snapshot names its archives +# for the most recent tag in the checkout and reports a -snapshot version from the +# binary inside; see docs/reference/release-artifacts.md. +.PHONY: release-snapshot +release-snapshot: + $(GORELEASER) release --snapshot --clean + +# Checks .goreleaser.yaml without building anything. +.PHONY: release-check +release-check: + $(GORELEASER) check + # Proves the live runs still compile against the shell they drive. The default # gate cannot do this for them: the tag that keeps them out of it also keeps # them from being built by it, so without this target they rot silently. diff --git a/docs/README.md b/docs/README.md index a4823fe..e0c0e98 100644 --- a/docs/README.md +++ b/docs/README.md @@ -37,6 +37,10 @@ requirements or architecture. ## Reference and examples - [Proposed shell commands](reference/commands.md) +- [Release artifacts](reference/release-artifacts.md) is the naming, checksum, + and version contract between a published release and the programs that + download from it. Unlike the documents beside it, it describes what a release + actually publishes rather than a proposed interface. - [Authentication context examples](examples/authentication-contexts.md) These documents illustrate proposed interfaces. They are not evidence that the diff --git a/docs/reference/release-artifacts.md b/docs/reference/release-artifacts.md new file mode 100644 index 0000000..943c051 --- /dev/null +++ b/docs/reference/release-artifacts.md @@ -0,0 +1,162 @@ +# Release Artifacts + +**Status:** Accepted +**Related:** [Distribution research](../research/root-cli-installation-distribution.md), +[architecture](../architecture.md) +**Last reviewed:** 2026-08-10 + +This document is the naming contract between a published release and the +programs that download from it. An install script derives every URL it needs +from a resolved tag and this convention alone: no manifest, no index, and no API +call beyond resolving the tag. Changing anything here breaks installers that are +already in users' hands, so it changes only with a deliberate migration. + +The configuration that implements this is `.goreleaser.yaml`, and the workflow +that publishes it is `.github/workflows/release.yml`. + +## Where artifacts are published + +GitHub Releases on `wso2/wso2-cli`. A pushed tag matching `v*` publishes one +release named for that tag. + +This is the interim distribution channel. The signed, per-platform channels in +[the distribution research](../research/root-cli-installation-distribution.md) +remain the destination, and the archives described here are the inputs those +channels package. + +## Archive names + +```text +wso2-cli---. +``` + +| Component | Values | +| ------------- | ----------------------------------------------- | +| `` | The Git tag verbatim, including its leading `v` | +| `` | `linux`, `darwin`, `windows` | +| `` | `amd64`, `arm64`, `arm`, `386` | +| `` | `tar.gz` for Linux, `zip` for macOS and Windows | + +The tag appears verbatim so that a script which resolved a tag can build the +name without transforming it. + +The operating system and architecture tokens are not what platform detection +reports, and both have to be normalized before a name is built. + +On macOS and Linux, `uname -s` answers `Linux` and `Darwin`, which lower-case to +the tokens above, and `uname -m` answers a wider set, so `x86_64` maps to +`amd64`, `aarch64` to `arm64`, `armv6l` and `armv7l` to `arm`, and `i686` to +`386`. + +Windows does not go through `uname` at all. The operating system token is fixed: +anything installing from Windows is on `windows`, so nothing has to be detected +to choose it. The architecture comes from the environment instead, reading +`PROCESSOR_ARCHITEW6432` before `PROCESSOR_ARCHITECTURE` — a 32-bit process on +a 64-bit machine reports `x86` in the second and the real architecture only in +the first — and mapping `AMD64` to `amd64` and `ARM64` to `arm64`. + +Normalizing is not enough on its own: the result has to name a target the +release actually carries, and the table below is not the product of every +operating system with every architecture. There is no `windows` `386` archive +and no `darwin` `386` or `arm` archive, so an installer that normalized its way +to one of those has found a platform with nothing to install rather than a name +to fetch. That is a refusal, and it belongs before the download rather than as a +confusing 404 from it. The same holds for a machine reporting an architecture +absent from the list entirely. + +The `arm` archive is built for ARMv6, which the more common ARMv7 hardware also +runs. There is one `arm` archive rather than one per variant, so a script that +detected `arm` has a single name to build. + +## Supported targets + +A release carries exactly these eight archives: + +| Operating system | Architectures | +| ---------------- | ------------------------------ | +| Linux | `amd64`, `arm64`, `arm`, `386` | +| macOS | `amd64`, `arm64` | +| Windows | `amd64`, `arm64` | + +The pull-request cross-build check compiles this same list. The two are kept +equal deliberately: a target that could be released without being compiled on +every pull request would break in a tag rather than in the change that broke it. + +## Archive contents + +Each archive contains, at its root and in no subdirectory: + +- `wso2` — the shell binary, named `wso2.exe` on Windows +- `LICENSE` +- `NOTICE` + +An installer extracts the archive and moves one known path. The license and +notice travel with the binary because Apache-2.0 requires it. + +## Checksums + +Every release carries `checksums.txt`: one SHA-256 line per archive, in the +format `sha256sum` reads and writes. + +```text +https://github.com/wso2/wso2-cli/releases/download//checksums.txt +``` + +The install scripts fetch this file and verify the archive they downloaded +before extracting it. Verification failure is fatal: nothing is extracted and +nothing is installed. + +Artifacts are not signed or notarized. Code signing belongs to the per-platform +channels, and until then integrity rests on this checksum file and on HTTPS. + +## Version reporting + +A released binary reports the version it was built as. The release injects the +shell version — the tag with its leading `v` removed, because the version +package prefixes one for display — and the protocol version through the +build-time variables in `internal/version`. + +The release workflow proves this rather than assuming it. It downloads the +published assets back from the release page, checks that the published checksum +file is the one that was built and that it lists every archive beside it, then +extracts the Linux archive and runs `wso2 version`. The release fails if the +binary reports the development placeholder, reports a version unrelated to the +tag, or reports a protocol version that disagrees with the shell's own source +default. + +## Prereleases + +A tag carrying a prerelease identifier, such as `v0.2.0-rc.1`, publishes as a +GitHub prerelease. Resolving "the latest release" skips prereleases, so a +release candidate never becomes the default for users who ask for the newest +version; the install scripts reach it only through their prerelease channel. + +## Download URLs + +```text +https://github.com/wso2/wso2-cli/releases/download//wso2-cli---. +https://github.com/wso2/wso2-cli/releases/download//checksums.txt +``` + +The newest stable tag can be resolved without an API token by following the +redirect on `https://github.com/wso2/wso2-cli/releases/latest` and reading the +tag from the resulting URL. + +## Reproducing a release without publishing + +```sh +make release-snapshot +``` + +This builds every artifact into `dist/`, including `checksums.txt`, and +publishes nothing. It is how a change to the release configuration is checked +before a tag exists. + +A snapshot differs from a real release in two ways worth knowing before reading +its output: the archive names carry the most recent tag in the checkout, which +is `v0.0.0` when there is none, while the binary inside reports the next patch +version suffixed with `-snapshot`. Everything else — the target list, the +archive formats, the archive contents, and the checksum file — is what a tag +produces. + +`make release-check` validates the configuration without building. diff --git a/internal/auth/fakeissuer/fakeissuer.go b/internal/auth/fakeissuer/fakeissuer.go index 4db86ae..8f5e59c 100644 --- a/internal/auth/fakeissuer/fakeissuer.go +++ b/internal/auth/fakeissuer/fakeissuer.go @@ -146,7 +146,11 @@ type Options struct { // deployment should say and nothing constrains what one can say, and a // client that carried such a value into its own arithmetic would fail in a // way no refusal describes. - DeviceInterval int + // + // It is an int64 because the shell parses the member into one, so a test may + // advertise a value beyond a 32-bit int on any platform. An int here would + // instead stop the 32-bit builds compiling at the test that asks for one. + DeviceInterval int64 // DeviceExpiresIn is the lifetime the device authorization response // advertises, in seconds. The default is 600, which is the order of // magnitude real deployments publish. diff --git a/test/acceptance/login_device_test.go b/test/acceptance/login_device_test.go index e0eec57..e69ece0 100644 --- a/test/acceptance/login_device_test.go +++ b/test/acceptance/login_device_test.go @@ -368,7 +368,7 @@ func TestAHostileAdvertisedIntervalRefusesRatherThanCrashes(t *testing.T) { // // What both share is the property under test. Neither ends in a panic. for name, testcase := range map[string]struct { - advertised int + advertised int64 expiresIn int want exit.Code }{