diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index a31f4b8..909638e 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -65,3 +65,22 @@ jobs: with: go-version-file: go.mod go-package: ./... + + # Builds every release target without signing or publishing, so changes to + # .goreleaser.yaml are caught before a tag is pushed. + release-snapshot: + name: release-snapshot + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + - uses: goreleaser/goreleaser-action@a4f02ab5355a589de60d917a659ce617ed19b3de # v7.3.0 + with: + distribution: goreleaser + version: "~> v2" + args: release --snapshot --clean --skip=sign,sbom diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..40073d1 --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,26 @@ +name: release-please + +# Keeps a release PR open with the next version and changelog. Merging it +# tags vX.Y.Z, which triggers the release workflow. +on: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +jobs: + release-please: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 + with: + config-file: release-please-config.json + manifest-file: .release-please-manifest.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..dbe6f11 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,47 @@ +name: release + +# Builds, signs and publishes a release for a vX.Y.Z tag created by +# release-please. Runs only on tags pushed to this repository, never on +# pull requests, so it never sees untrusted code. +on: + push: + tags: ["v*"] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + goreleaser: + runs-on: ubuntu-latest + permissions: + contents: write # upload release assets + id-token: write # cosign keyless signing and provenance + attestations: write # build-provenance attestations + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + cache: false + - uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + - uses: anchore/sbom-action/download-syft@66cbf4bc1f1c0d2edc94016e65bc221b6bb0ad6c # v0.24.3 + - uses: goreleaser/goreleaser-action@a4f02ab5355a589de60d917a659ce617ed19b3de # v7.3.0 + with: + distribution: goreleaser + version: "~> v2" + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-path: | + dist/*.tar.gz + dist/*.zip + dist/*_checksums.txt diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..f000978 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,79 @@ +# yaml-language-server: $schema=https://goreleaser.com/static/schema.json +version: 2 + +project_name: devlyft + +before: + hooks: + - go mod verify + +builds: + - id: devlyft + main: ./cmd/devlyft + binary: devlyft + env: + - CGO_ENABLED=0 + goos: [linux, darwin, windows] + goarch: [amd64, arm64] + flags: + - -trimpath + ldflags: + - -s -w + - -X github.com/devlyft/cli/internal/version.Version={{ .Version }} + - -X github.com/devlyft/cli/internal/version.Commit={{ .FullCommit }} + - -X github.com/devlyft/cli/internal/version.Date={{ .CommitDate }} + # Reproducible builds: file timestamps come from the commit, not the clock. + mod_timestamp: "{{ .CommitTimestamp }}" + +archives: + - id: devlyft + ids: [devlyft] + formats: [tar.gz] + format_overrides: + - goos: windows + formats: [zip] + name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + files: + - LICENSE + - README.md + +checksum: + name_template: "{{ .ProjectName }}_{{ .Version }}_checksums.txt" + algorithm: sha256 + +# One SBOM per archive, generated by syft. +sboms: + - artifacts: archive + +# Keyless signing of the checksum file with the release workflow's GitHub +# OIDC identity. The checksum file covers every archive and SBOM, so one +# signature covers the whole release. +signs: + - cmd: cosign + artifacts: checksum + signature: "${artifact}.sig" + certificate: "${artifact}.pem" + args: + - sign-blob + - "--output-signature=${signature}" + - "--output-certificate=${certificate}" + - "--bundle=${artifact}.sigstore.json" + - "${artifact}" + - "--yes" + output: true + +# release-please writes the changelog and the release notes. +changelog: + disable: true + +release: + github: + owner: devlyft + name: cli + # release-please has already created the release; goreleaser only attaches + # the artifacts and keeps its notes. + mode: keep-existing + prerelease: auto + +snapshot: + version_template: "{{ incpatch .Version }}-next" diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..e18ee07 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.0.0" +} diff --git a/Makefile b/Makefile index c740f87..198e754 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,14 @@ # Tool versions are pinned here and in .github/workflows; keep them in sync. GOLANGCI_LINT_VERSION ?= v2.15.0 GOVULNCHECK_VERSION ?= v1.9.0 +GORELEASER_VERSION ?= v2.19.0 BIN ?= bin/devlyft VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) LDFLAGS := -s -w -X github.com/devlyft/cli/internal/version.Version=$(VERSION) .DEFAULT_GOAL := build -.PHONY: build test cover lint fmt vuln tidy check clean help +.PHONY: build test cover lint fmt vuln tidy check snapshot clean help build: ## Build the devlyft binary into bin/ go build -trimpath -ldflags '$(LDFLAGS)' -o $(BIN) ./cmd/devlyft @@ -33,8 +34,11 @@ tidy: ## Tidy go.mod and go.sum check: lint test vuln ## Run everything CI runs +snapshot: ## Build unsigned release archives for every platform into dist/ + go run github.com/goreleaser/goreleaser/v2@$(GORELEASER_VERSION) release --snapshot --clean --skip=sign,sbom + clean: - rm -rf bin coverage.out + rm -rf bin dist coverage.out help: ## Show this help @grep -E '^[a-z]+:.*## ' $(MAKEFILE_LIST) | awk -F':.*## ' '{printf " %-8s %s\n", $$1, $$2}' diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 0000000..eb0283a --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,57 @@ +# Releasing + +Releases are automated. Nobody tags by hand. + +## How a release happens + +1. Pull requests are squash-merged into `main` with a [Conventional Commits](https://www.conventionalcommits.org/) title (enforced by the `Lint title` check). +2. On every push to `main`, [release-please](https://github.com/googleapis/release-please) updates an open release PR with the next version and the changelog, worked out from those titles. +3. Merging the release PR creates the GitHub release and the `vX.Y.Z` tag. +4. The tag triggers `.github/workflows/release.yml`, which runs [goreleaser](https://goreleaser.com/) on a GitHub-hosted runner to: + - build `devlyft` for Linux, macOS and Windows on amd64 and arm64 (`CGO_ENABLED=0`, `-trimpath`, version, commit and date stamped in); + - package `.tar.gz` archives (`.zip` on Windows) with an SPDX SBOM per archive, generated by syft; + - write `devlyft__checksums.txt` covering every archive and SBOM; + - sign the checksum file with [cosign](https://docs.sigstore.dev/) keyless signing, using the workflow's GitHub OIDC identity (no stored keys), producing `.sig`, `.pem` and `.sigstore.json`; + - attach everything to the release. +5. The workflow then records a GitHub build-provenance attestation for the archives and the checksum file. + +Every pull request also runs `checks / release-snapshot`, which builds all targets with the same config but skips signing, SBOMs and publishing. Run the same thing locally with `make snapshot`; the output lands in `dist/`. + +## Verifying a download + +Download the archive, `devlyft__checksums.txt` and `devlyft__checksums.txt.sigstore.json` from the release, then: + +```sh +cosign verify-blob \ + --bundle devlyft__checksums.txt.sigstore.json \ + --certificate-identity-regexp '^https://github\.com/devlyft/cli/\.github/workflows/release\.yml@refs/tags/v' \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + devlyft__checksums.txt + +sha256sum --ignore-missing -c devlyft__checksums.txt +``` + +With the GitHub CLI you can check the build provenance instead: + +```sh +gh attestation verify devlyft__linux_amd64.tar.gz --repo devlyft/cli +``` + +## Versioning: 0.x and 1.0 + +The CLI follows [semantic versioning](https://semver.org/). + +- **While 0.x**, a minor release may contain breaking changes (`feat!:` and `BREAKING CHANGE` bump the minor, not the major). Patch releases never break. +- **1.0** is cut once all of these are true: + - browser login (OAuth 2.0 with PKCE) is released ([devlyft/devlyft#263](https://github.com/devlyft/devlyft/issues/263)); + - device login is released ([devlyft/devlyft#264](https://github.com/devlyft/devlyft/issues/264)); + - the output-stability and deprecation policy is published. +- 1.0 is **not** gated on server-side pagination or rollout status. Until those ship, `--limit` and `deploy --wait` are documented as best-effort. +- **From 1.0**, breaking the documented JSON or YAML output, exit codes or error `code` strings requires a major release. + +To cut 1.0, merge a commit containing `Release-As: 1.0.0` in its body; release-please will propose 1.0.0 in its next release PR. + +## Known gaps + +- macOS binaries are not notarized and Windows binaries are not code-signed, so browser downloads trigger Gatekeeper and SmartScreen warnings. Installing through Homebrew or the install script avoids this. +- Homebrew, Scoop, deb and rpm channels are tracked separately (devlyft/devlyft#258, devlyft/devlyft#260). diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..5bcda39 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "release-type": "go", + "include-component-in-tag": false, + "bump-minor-pre-major": true, + "packages": { + ".": { + "package-name": "devlyft", + "changelog-path": "CHANGELOG.md" + } + } +}