From e0b6f1b79016ee210a08939b595594070b8189d7 Mon Sep 17 00:00:00 2001 From: Geno Roupsky <185580+groupsky@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:55:30 +0300 Subject: [PATCH] ci: Go module, CI, lint and release-please setup --- .github/dependabot.yml | 27 ++++ .github/workflows/ci.yml | 112 +++++++++++++++ .github/workflows/pr-title.yml | 42 ++++++ .github/workflows/release.yml | 150 +++++++++++++++++++++ .gitignore | 18 +++ .golangci.yml | 58 ++++++++ .release-please-manifest.json | 3 + cmd/nodkey/main.go | 95 +++++++++++++ cmd/nodkey/main_test.go | 59 ++++++++ docs/releasing.md | 240 +++++++++++++++++++++++++++++++++ go.mod | 3 + release-please-config.json | 25 ++++ 12 files changed, 832 insertions(+) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/pr-title.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 .golangci.yml create mode 100644 .release-please-manifest.json create mode 100644 cmd/nodkey/main.go create mode 100644 cmd/nodkey/main_test.go create mode 100644 docs/releasing.md create mode 100644 go.mod create mode 100644 release-please-config.json diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..49c8daa --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,27 @@ +version: 2 +updates: + # Pull request titles look like "chore(deps): bump ...". The chore type + # makes no release. See docs/releasing.md for security fixes. + - package-ecosystem: gomod + directory: / + schedule: + interval: weekly + # Wait 7 days after a new version comes out, so that a bad release can + # be found and pulled first. Security updates do not wait. + cooldown: + default-days: 7 + commit-message: + prefix: chore + include: scope + + # Pull request titles look like "ci(deps): bump actions/checkout ...". + # Dependabot also updates the pinned commit SHA and its version comment. + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + cooldown: + default-days: 7 + commit-message: + prefix: ci + include: scope diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2c3af49 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,112 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + schedule: + # Every Monday: find vulnerabilities that became known since the last push. + - cron: "17 5 * * 1" + +permissions: + contents: read + +concurrency: + # A new push to a pull request cancels the older run for it. + # Each push to main gets its own group, so those runs are never cancelled. + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + +jobs: + test: + name: test (${{ matrix.go }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # go.mod: the oldest Go we support (the go line in go.mod). + # stable: the newest Go release. + go: [go.mod, stable] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: ${{ matrix.go == 'go.mod' && 'go.mod' || '' }} + go-version: ${{ matrix.go == 'stable' && 'stable' || '' }} + - name: Check formatting + run: | + unformatted="$(gofmt -l .)" + if [ -n "$unformatted" ]; then + echo "These files are not formatted with gofmt:" + echo "$unformatted" + exit 1 + fi + - run: go vet ./... + - run: go test -race ./... + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + - uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 + with: + # Dependabot does not update this version. Update it by hand. + version: v2.14.0 + + vuln: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + # Release binaries are built with the newest Go (see release.yml), + # so check the standard library of that version. + go-version: stable + - name: govulncheck + # Dependabot does not update this version. Update it by hand. + run: go run golang.org/x/vuln/cmd/govulncheck@v1.9.0 ./... + + vuln-release: + name: vuln (latest release) + # Weekly only. The published binaries contain the Go standard library + # they were built with, so check the binaries themselves. A finding here + # needs a new release (see docs/releasing.md). + if: github.event_name == 'schedule' + runs-on: ubuntu-latest + steps: + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: stable + cache: false + - name: govulncheck on the binaries of the latest release + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + tag="$(gh release list --repo "$GITHUB_REPOSITORY" --exclude-drafts \ + --exclude-pre-releases --limit 1 --json tagName --jq '.[0].tagName // empty')" + if [ -z "$tag" ]; then + echo "No release yet. Nothing to check." + exit 0 + fi + dir="${RUNNER_TEMP}/release" + gh release download "$tag" --repo "$GITHUB_REPOSITORY" --dir "$dir" \ + --pattern 'nodkey_*_linux_*.tar.gz' + status=0 + for tarball in "$dir"/*.tar.gz; do + out="${tarball%.tar.gz}" + mkdir -p "$out" + tar -xzf "$tarball" -C "$out" nodkey + echo "== ${tag}: $(basename "$tarball")" + go run golang.org/x/vuln/cmd/govulncheck@v1.9.0 -mode=binary "$out/nodkey" || status=1 + done + exit "$status" diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml new file mode 100644 index 0000000..954cb91 --- /dev/null +++ b/.github/workflows/pr-title.yml @@ -0,0 +1,42 @@ +name: PR title + +# The squash merge uses the pull request title as the commit message, and +# release-please reads those messages. So the title must follow +# Conventional Commits. See docs/releasing.md. + +on: + # pull_request_target also runs for pull requests from forks. It runs with + # this repository's token, so this workflow must never check out or run + # code from the pull request. It only reads the title. + pull_request_target: + types: [opened, edited, synchronize, reopened] + +permissions: + pull-requests: read # read the pull request + +concurrency: + # Check only the newest title of a pull request. + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + title: + name: Conventional Commits title + runs-on: ubuntu-latest + steps: + - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + types: | + feat + fix + perf + revert + docs + chore + ci + test + refactor + build + style diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..5d1603e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,150 @@ +name: Release + +# How releases work, and the one-time setup: docs/releasing.md. + +on: + push: + branches: [main] + +# Each job asks for only the permissions it needs. +permissions: {} + +concurrency: + # One release run at a time. Never cancel a run in the middle of a release. + group: ${{ github.workflow }} + cancel-in-progress: false + +jobs: + release-please: + # Skip until the GitHub App is set up (see docs/releasing.md). + if: vars.RELEASE_APP_CLIENT_ID != '' + runs-on: ubuntu-latest + # The App private key is a secret of this environment. Only main may use + # the environment, so only runs on main can read the key. + environment: release + # This job uses the App token, not GITHUB_TOKEN. + permissions: {} + outputs: + release_created: ${{ steps.release.outputs.release_created }} + tag_name: ${{ steps.release.outputs.tag_name }} + version: ${{ steps.release.outputs.version }} + steps: + - id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.RELEASE_APP_CLIENT_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + permission-contents: write + permission-pull-requests: write + - id: release + uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 + with: + token: ${{ steps.app-token.outputs.token }} + config-file: release-please-config.json + manifest-file: .release-please-manifest.json + + # Builds the files. It can only read the repository: no OIDC token, and no + # write access to the release. + build: + needs: release-please + if: needs.release-please.outputs.release_created == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + env: + TAG: ${{ needs.release-please.outputs.tag_name }} + VERSION: ${{ needs.release-please.outputs.version }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: refs/tags/${{ needs.release-please.outputs.tag_name }} + persist-credentials: false + - name: Check that the tag is the commit of this run + # The provenance attestation names $GITHUB_SHA as the source commit. + # Build only that commit. + run: | + set -euo pipefail + head="$(git rev-parse HEAD)" + tagged="$(git rev-parse "refs/tags/${TAG}^{commit}")" + echo "HEAD: $head tag ${TAG}: $tagged run: $GITHUB_SHA" + if [ "$head" != "$GITHUB_SHA" ] || [ "$tagged" != "$GITHUB_SHA" ]; then + echo "::error::The tag ${TAG} is not the commit of this run. See docs/releasing.md." + exit 1 + fi + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + # The newest Go, so the binaries get the newest standard library fixes. + go-version: stable + # No cache for release builds: a bad cache entry could change the binaries. + cache: false + - name: Build + # -w drops the debug data. The symbol table stays (no -s): govulncheck + # needs it to check the published binaries (see ci.yml). + run: | + set -euo pipefail + mkdir -p dist + # Fixed owner, mode and time in the tarball, so a rebuild of the + # same commit with the same Go version gives the same file. + mtime="@$(git log -1 --format=%ct)" + for arch in amd64 arm64; do + name="nodkey_${VERSION}_linux_${arch}" + stage="${RUNNER_TEMP}/${name}" + mkdir -p "$stage" + CGO_ENABLED=0 GOOS=linux GOARCH="$arch" \ + go build -trimpath -ldflags "-w -X main.version=${VERSION}" \ + -o "${stage}/nodkey" ./cmd/nodkey + cp LICENSE README.md "$stage/" + tar --owner=0 --group=0 --numeric-owner --mode=go-w --mtime="$mtime" \ + -C "$stage" -cf - nodkey LICENSE README.md | gzip -9n > "dist/${name}.tar.gz" + done + cd dist + sha256sum -- *.tar.gz > SHA256SUMS + cat SHA256SUMS + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: release-files + path: dist/ + if-no-files-found: error + + # Signs and publishes the files from the build job. It runs no code from + # the repository. + publish: + needs: [release-please, build] + if: needs.release-please.outputs.release_created == 'true' + runs-on: ubuntu-latest + permissions: + contents: write # upload the files and publish the draft release + id-token: write # sign the attestation (Sigstore) + attestations: write # store the attestation + env: + TAG: ${{ needs.release-please.outputs.tag_name }} + steps: + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: release-files + path: dist + - name: Check the files and the tag + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + (cd dist && sha256sum --strict -c SHA256SUMS) + tagged="$(gh api "repos/${GITHUB_REPOSITORY}/commits/refs/tags/${TAG}" --jq .sha)" + if [ "$tagged" != "$GITHUB_SHA" ]; then + echo "::error::The tag ${TAG} points to ${tagged}, not to ${GITHUB_SHA}. See docs/releasing.md." + exit 1 + fi + - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: dist/*.tar.gz + # release-please made a draft release (see release-please-config.json). + # Add the files first and publish after that, so the release is never + # public without its files. Immutable releases need this order. + - name: Upload the files to the draft release + env: + GH_TOKEN: ${{ github.token }} + run: gh release upload "$TAG" dist/*.tar.gz dist/SHA256SUMS --clobber --repo "$GITHUB_REPOSITORY" + - name: Publish the release + env: + GH_TOKEN: ${{ github.token }} + run: gh release edit "$TAG" --draft=false --repo "$GITHUB_REPOSITORY" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..36bbdfb --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Release build output +/dist/ + +# Binary from "go build ./..." or "go build ./cmd/nodkey" +/nodkey + +# Test coverage output +*.out +*.coverprofile +coverage.html + +# Editor and OS files +.idea/ +.vscode/ +*.swp +*.swo +*~ +.DS_Store diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..e64c2b7 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,58 @@ +# golangci-lint configuration, v2 format. +# Reference: https://golangci-lint.run/docs/configuration/file/ +version: "2" + +linters: + # standard: errcheck, govet, ineffassign, staticcheck, unused. + default: standard + enable: + - bodyclose # HTTP response bodies are closed + - errorlint # errors are checked with errors.Is/As and wrapped with %w + - forbidigo # library code does not print (see settings) + - gosec # security problems + - misspell # spelling mistakes in comments and strings + - noctx # network calls take a context.Context + - nolintlint # //nolint comments name the linter and give a reason + - unconvert # type conversions that do nothing + + settings: + forbidigo: + # Library code must never print or log on its own: a secret could end + # up in a terminal or a log. Library code gets an io.Writer or a + # *slog.Logger from its caller, and returns values and errors. + # Code in cmd/ talks to the user, so it may print (see exclusions below). + forbid: + - pattern: ^fmt\.Print.*$ + msg: library code must not print; return the value or an error + - pattern: ^print(ln)?$ + msg: library code must not print; return the value or an error + - pattern: ^os\.(Stdout|Stderr)$ + msg: library code must not use stdout or stderr; take an io.Writer + - pattern: ^log\.(Logger\.)?(Print|Fatal|Panic).*$ + msg: library code must not log; return an error or take a *slog.Logger + - pattern: ^log\.(Logger\.)?(Output|SetOutput)$ + msg: library code must not log; return an error or take a *slog.Logger + - pattern: ^slog\.(Debug|Info|Warn|Error|Log)(Context|Attrs)?$ + msg: library code must not use the default logger; take a *slog.Logger + - pattern: ^slog\.(Default|SetDefault)$ + msg: library code must not use the default logger; take a *slog.Logger + # Match on types, so an import alias or a *log.Logger cannot hide a call. + analyze-types: true + nolintlint: + require-explanation: true + require-specific: true + + exclusions: + rules: + - path: ^cmd/ + linters: + - forbidigo + +formatters: + enable: + - gofmt + - goimports + settings: + goimports: + local-prefixes: + - github.com/code6x/nodkey 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/cmd/nodkey/main.go b/cmd/nodkey/main.go new file mode 100644 index 0000000..4f0e9b5 --- /dev/null +++ b/cmd/nodkey/main.go @@ -0,0 +1,95 @@ +// Command nodkey lets an AI coding agent use a secret or an SSH key only +// after a person approves that one request on a phone. +package main + +import ( + "errors" + "flag" + "fmt" + "io" + "os" +) + +// version is the nodkey version. Release builds set it with +// -ldflags "-X main.version=". +var version = "dev" + +// Exit codes. +const ( + exitOK = 0 + exitFailure = 1 + exitUsage = 64 // EX_USAGE from sysexits.h: the command was used wrongly. +) + +const usageText = `Usage: nodkey [arguments] + +Commands: + version print the version + help print this help +` + +func main() { + os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) +} + +// run runs nodkey with args (without the program name) and returns the +// exit code. +func run(args []string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet("nodkey", flag.ContinueOnError) + fs.SetOutput(stderr) + fs.Usage = func() { printUsage(stderr) } + if err := fs.Parse(args); err != nil { + return parseErrorCode(err) + } + if fs.NArg() == 0 { + printUsage(stderr) + return exitUsage + } + + name, rest := fs.Arg(0), fs.Args()[1:] + switch name { + case "version": + return runVersion(rest, stdout, stderr) + case "help": + if _, err := io.WriteString(stdout, usageText); err != nil { + return exitFailure + } + return exitOK + default: + _, _ = fmt.Fprintf(stderr, "nodkey: unknown command %q\n\n", name) + printUsage(stderr) + return exitUsage + } +} + +func runVersion(args []string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet("nodkey version", flag.ContinueOnError) + fs.SetOutput(stderr) + fs.Usage = func() { _, _ = io.WriteString(stderr, "Usage: nodkey version\n") } + if err := fs.Parse(args); err != nil { + return parseErrorCode(err) + } + if fs.NArg() > 0 { + fs.Usage() + return exitUsage + } + if _, err := fmt.Fprintf(stdout, "nodkey %s\n", version); err != nil { + return exitFailure + } + return exitOK +} + +// printUsage writes the usage text to w. A failed write to stderr cannot +// be reported anywhere, so the error is ignored. +func printUsage(w io.Writer) { + _, _ = io.WriteString(w, usageText) +} + +// parseErrorCode returns the exit code for an error from flag parsing. +// The flag package has already printed the message and the usage. +func parseErrorCode(err error) int { + if errors.Is(err, flag.ErrHelp) { + return exitOK + } + return exitUsage +} diff --git a/cmd/nodkey/main_test.go b/cmd/nodkey/main_test.go new file mode 100644 index 0000000..adb9da3 --- /dev/null +++ b/cmd/nodkey/main_test.go @@ -0,0 +1,59 @@ +package main + +import ( + "bytes" + "errors" + "strings" + "testing" +) + +func TestRun(t *testing.T) { + tests := []struct { + name string + args []string + wantCode int + wantStdout string + wantStderr string // a part of stderr; "" means stderr must be empty + }{ + {name: "no arguments", args: nil, wantCode: exitUsage, wantStderr: usageText}, + {name: "unknown command", args: []string{"bogus"}, wantCode: exitUsage, wantStderr: `unknown command "bogus"`}, + {name: "unknown flag", args: []string{"-bogus"}, wantCode: exitUsage, wantStderr: "flag provided but not defined"}, + {name: "-h", args: []string{"-h"}, wantCode: exitOK, wantStderr: usageText}, + {name: "help", args: []string{"help"}, wantCode: exitOK, wantStdout: usageText}, + {name: "version", args: []string{"version"}, wantCode: exitOK, wantStdout: "nodkey " + version + "\n"}, + {name: "version with an argument", args: []string{"version", "extra"}, wantCode: exitUsage, wantStderr: "Usage: nodkey version"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var stdout, stderr bytes.Buffer + code := run(tt.args, &stdout, &stderr) + if code != tt.wantCode { + t.Errorf("exit code = %d, want %d", code, tt.wantCode) + } + if got := stdout.String(); got != tt.wantStdout { + t.Errorf("stdout = %q, want %q", got, tt.wantStdout) + } + got := stderr.String() + if tt.wantStderr == "" && got != "" { + t.Errorf("stderr = %q, want it empty", got) + } + if !strings.Contains(got, tt.wantStderr) { + t.Errorf("stderr = %q, want it to contain %q", got, tt.wantStderr) + } + }) + } +} + +// failingWriter fails every write, like a closed pipe. +type failingWriter struct{} + +func (failingWriter) Write([]byte) (int, error) { return 0, errors.New("write failed") } + +func TestRunReportsFailedOutput(t *testing.T) { + for _, args := range [][]string{{"version"}, {"help"}} { + var stderr bytes.Buffer + if code := run(args, failingWriter{}, &stderr); code != exitFailure { + t.Errorf("run(%q) with a failing stdout: exit code = %d, want %d", args, code, exitFailure) + } + } +} diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 0000000..778d6c1 --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,240 @@ +# Releasing nodkey + +Releases are automatic. A tool called +[release-please](https://github.com/googleapis/release-please) makes them +from the commit messages on `main`. This page explains how it works, how the +repository is protected, and the one-time setup it needs. + +## Pull request titles + +Every pull request title must follow +[Conventional Commits](https://www.conventionalcommits.org/): + +```text +: +(): +``` + +Examples: + +- `feat: add the approve command` +- `fix(sign): reject an empty key name` +- `docs: explain the threat model` + +The allowed types are `feat`, `fix`, `perf`, `revert`, `docs`, `chore`, +`ci`, `test`, `refactor`, `build` and `style`. + +For a breaking change, add `!` after the type. For example: +`feat!: change the config file format`. + +The **PR title** check tests the title. If the check fails, edit the title. +The check runs again by itself. + +## Squash merges + +This repository allows only squash merges. When you merge a pull request, +GitHub makes one commit on `main`. The commit message is only the pull +request title. release-please reads this title. For `feat`, `fix`, `perf`, +`revert` and `docs`, the title also becomes a line in the changelog. + +release-please also reads the description of each merged pull request. A +`BEGIN_COMMIT_OVERRIDE` block there replaces the commit message, even after +the merge. Do not write such a block unless you mean it. This is one more +reason to read the release pull request carefully before you merge it. + +## What release-please does + +After every push to `main`, the **Release** workflow runs release-please. + +1. It reads the commit messages since the last release. +2. If one of them makes a release (see the table below), it opens a + *release pull request*. Its title looks like `chore(main): release 0.2.0`. + It changes `CHANGELOG.md` and `.release-please-manifest.json`. +3. Later merges to `main` update the same release pull request. You can + leave it open as long as you want. +4. **Before you merge the release pull request, read the version and every + line of the changelog in it.** This text becomes the release notes. +5. When you merge it, release-please creates the tag (for example `v0.2.0`) + and a **draft** GitHub release with the release notes. +6. Then two more jobs run in the same workflow: + - `build` can only read the repository. It checks that the tag is the + commit of this workflow run. Then it builds static `nodkey` binaries + for Linux amd64 and arm64 (`CGO_ENABLED=0`, `-trimpath`), packs each + one with `LICENSE` and `README.md` into + `nodkey__linux_.tar.gz`, and writes `SHA256SUMS`. + - `publish` gets these files from `build`. It checks them against + `SHA256SUMS` and checks the tag again. Then it makes a signed build + provenance attestation for the tarballs, uploads the files to the draft + release and publishes it. + +The release is never public without its files. Immutable releases are on +for this repository: after a release is published, nobody can change its +files or move its tag. This is why the files are added while the release is +still a draft. + +### If `build` or `publish` fails + +The release stays a draft. **Re-run failed jobs** uses the same commit and +the same workflow file as the first run. So a re-run only helps when the +failure was temporary, for example a network error. + +For any other problem, fix it in a new `fix:` pull request, then merge the +next release pull request. That release gets a new version. You can delete +the old draft release. Its tag stays, because tags cannot be deleted. + +## Which types make a release + +| Type | Section in the release notes | Makes a release? | +|---|---|---| +| `feat` | Features | yes | +| `fix` | Bug Fixes | yes | +| `perf` | Performance Improvements | yes | +| `revert` | Reverts | yes | +| `docs` | Documentation | yes | +| `chore`, `ci`, `test`, `refactor`, `build`, `style` | not shown | no | + +If `main` has only `chore`, `ci`, `test`, `refactor`, `build` or `style` +commits since the last release, release-please does not open a release pull +request. These changes go out with the next release, but the release notes +do not list them. + +## Versions before 1.0 + +- The first release is `0.1.0`. This is `initial-version` in + `release-please-config.json`. +- `feat` makes a minor release: `0.1.0` → `0.2.0`. +- A breaking change (`!`) also makes only a minor release before 1.0, not + `1.0.0`. This is `bump-minor-pre-major` in `release-please-config.json`. +- `fix`, `perf`, `revert` and `docs` make a patch release: `0.1.0` → `0.1.1`. + +## Dependency updates + +Dependabot checks Go modules and GitHub Actions once a week. Its pull request +titles start with `chore(deps):` or `ci(deps):`, so they do not make a +release. + +If an update fixes a security problem in a Go module that nodkey uses, +change the pull request title to `fix(deps): ...` before you merge it. Then +the fix gets a release. + +## Vulnerability checks + +The **CI** workflow runs govulncheck on every pull request, on every push to +`main`, and every Monday: + +- on the code, with the newest Go; +- on Mondays also on the binaries of the newest release. They contain the + Go standard library of the Go version that built them. + +A problem in the standard library of the published binaries does not go +away by itself. Only a new release fixes those binaries. Make a `fix:` pull +request, for example one that raises the `go` line in `go.mod` to a Go +release with the fix. Then merge the release pull request. The new release +is built with the newest Go. + +GitHub turns off scheduled workflows in a public repository after 60 days +without activity. If this happens, turn on the CI workflow again in the +**Actions** tab. + +## Repository settings + +These settings protect `main` and the releases: + +- **Ruleset for `main`**: changes only through a pull request; the CI checks + and the PR title check must pass; no force push; `main` cannot be deleted. +- **Ruleset for `v*` tags**: a tag cannot be moved or deleted. +- **Immutable releases**: on. +- **Actions**: every action must be pinned to a full commit SHA. Dependabot + keeps these SHAs up to date. +- **Environment `release`**: only `main` can use it. It holds the private + key of the GitHub App (see below). + +## One-time setup: a GitHub App + +Until this setup is done, the `release-please` job skips itself. In the +**Release** workflow runs it shows as "skipped". Nothing else breaks. + +### Why not the default `GITHUB_TOKEN` + +release-please could use the built-in `GITHUB_TOKEN`. But GitHub does not +start workflows for a pull request or a push that `GITHUB_TOKEN` made. So CI +and the PR title check would not run on the release pull request, and it +could not pass the required checks. A token from a GitHub App does start +workflows. + +An App is also better than a personal access token: it does not belong to a +person, it has only the permissions it needs, and each token from it expires +after one hour. + +### Steps + +1. Create the App. In the **code6x** organization, open + **Settings → Developer settings → GitHub Apps → New GitHub App**. + - **GitHub App name**: any free name, for example `code6x-nodkey-release`. + - **Homepage URL**: `https://github.com/code6x/nodkey`. + - **Webhook**: clear the **Active** box. The App needs no webhook. + - **Repository permissions**: + - **Contents**: Read and write. It creates the release branch, the tag + and the release. + - **Pull requests**: Read and write. It opens the release pull request + and adds labels and comments to it. + - Leave all other permissions at "No access". GitHub adds + **Metadata: Read-only** by itself. + - **Where can this GitHub App be installed?**: **Only on this account**. + - Click **Create GitHub App**. +2. On the App page, copy the **Client ID**. +3. On the same page, under **Private keys**, click + **Generate a private key**. Your browser downloads a `.pem` file. +4. Install the App: **Install App** → **code6x** → + **Only select repositories** → `code6x/nodkey` → **Install**. +5. Save the Client ID as a repository variable, and the private key as a + secret of the `release` environment: + + ```sh + gh variable set RELEASE_APP_CLIENT_ID --repo code6x/nodkey --body '' + gh secret set RELEASE_APP_PRIVATE_KEY --repo code6x/nodkey --env release < path/to/key.pem + ``` + + On the web, the variable is under + **Settings → Secrets and variables → Actions → Variables**, and the + secret is under **Settings → Environments → release → + Environment secrets**. Do not save the key as a repository secret: then + workflow runs on other branches could read it too. +6. Delete the `.pem` file from your computer. If you need a key again, + generate a new one on the App page and delete the old one there. +7. Let only the App create release tags. Open + **Settings → Rules → Rulesets**, open the ruleset for `v*` tags, turn on + **Restrict creations**, and add the App to the bypass list. The App must + be the only entry in that list. + +The next push to `main` runs release-please with the App. + +## Verify a download + +Download the tarball for your machine and `SHA256SUMS` from the release +page, or use `gh`: + +```sh +gh release download v0.1.0 --repo code6x/nodkey \ + --pattern 'nodkey_0.1.0_linux_amd64.tar.gz' --pattern SHA256SUMS +``` + +1. Check the checksum: + + ```sh + sha256sum -c --ignore-missing SHA256SUMS + ``` + + It must print `nodkey_0.1.0_linux_amd64.tar.gz: OK`. `--ignore-missing` + skips the tarballs that you did not download. + +2. Check the build provenance attestation. It proves that the Release + workflow on `main` of `code6x/nodkey` built this exact file, on a runner + that GitHub hosts: + + ```sh + gh attestation verify nodkey_0.1.0_linux_amd64.tar.gz \ + --repo code6x/nodkey \ + --cert-identity https://github.com/code6x/nodkey/.github/workflows/release.yml@refs/heads/main \ + --deny-self-hosted-runners + ``` diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..edfda71 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/code6x/nodkey + +go 1.26.0 diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..935b6af --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,25 @@ +{ + "$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, + "initial-version": "0.1.0", + "draft": true, + "force-tag-creation": true, + "changelog-sections": [ + { "type": "feat", "section": "Features" }, + { "type": "fix", "section": "Bug Fixes" }, + { "type": "perf", "section": "Performance Improvements" }, + { "type": "revert", "section": "Reverts" }, + { "type": "docs", "section": "Documentation" }, + { "type": "chore", "section": "Miscellaneous Chores", "hidden": true }, + { "type": "ci", "section": "Continuous Integration", "hidden": true }, + { "type": "test", "section": "Tests", "hidden": true }, + { "type": "refactor", "section": "Code Refactoring", "hidden": true }, + { "type": "build", "section": "Build System", "hidden": true }, + { "type": "style", "section": "Styles", "hidden": true } + ], + "packages": { + ".": {} + } +}