Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/taskgate-allow.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@
# would be in the module graph of every repository that imports it.
.github/workflows/ci.yml/gate/Task's pin # read before Task is installed
.github/workflows/pages.yml/build/Task's pin # read before Task is installed
.github/workflows/ci.yml/release/Task's pin # read before Task is installed
41 changes: 41 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,44 @@ jobs:
archive: false
retention-days: 7
if-no-files-found: ignore

# Main's commit, once the gate has passed on it, as the binaries a repository's `go tool task`
# runs (cmd/task): every command for each platform, attested as built here from this commit, and
# published as the release bin-<commit time> that a versions.yaml pins by sha256.
release:
name: Release
needs: gate
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: write
id-token: write
attestations: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod

- name: Task's pin
run: go run ./cmd/versions env task >> "$GITHUB_ENV"

- uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0
with:
version: ${{ env.TASK_VERSION }}
checksum: ${{ env.TASK_SHA256 }}

- name: The binaries
run: task release:build

- name: Their provenance
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-path: _output/release/*.tar.gz

- name: The release
env:
GH_TOKEN: ${{ github.token }}
run: task release:publish
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,18 @@ their `go.mod`; each fails CI with a specific finding and says how to resolve it
| [`linkweight`](https://spin-stack.github.io/go-tools/linkweight/) | what each binary links and why: the bytes every module puts in it, the function that stops the linker dropping unused methods, the import whose cut drops the most, and a capability - exec, listen, mount, setuid - no line of the allowlist explains |
| [`proven`](https://spin-stack.github.io/go-tools/proven/) | what CI already proved about a commit, by its tree: the run that answers for it, what a run on main is of, whether it may be released, each test's time, which failed tests are flaky |
| [`exposure`](https://spin-stack.github.io/go-tools/exposure/) | what a repository would show if made public: secrets and private values in every ref's commits, pull request heads included, and in its issues, comments and releases |
| [`task`](https://spin-stack.github.io/go-tools/start/) | not a check: Task at the version `versions.yaml` pins, with every other go-tools command on its `PATH`, both downloaded as released binaries - the one command a repository's `go.mod` takes, so neither Task's modules nor the tools' are compiled or cached |
| [`gencache`](https://spin-stack.github.io/go-tools/gencache/) | not a check: `go generate` with each mockgen directive answered from a cache when nothing its mock is made from has changed |
| [`ghatrace`](https://spin-stack.github.io/go-tools/ghatrace/) | not a check: where a GitHub Actions run's time went - the critical path, each job's wait for a runner, its steps and the spans inside them - as the run's summary, an HTML page and a Perfetto trace, with nothing hosted |
| [`provenance`, `sbom`, `attestgate`, `trustroot`](https://spin-stack.github.io/go-tools/supply-chain/) | releases published without SLSA provenance or an SBOM |

```sh
go get -tool github.com/spin-stack/go-tools/cmd/mutate
go tool mutate -base origin/main
go get -tool github.com/spin-stack/go-tools/cmd/task # and task + go-tools in versions.yaml
go tool task mutate # a task that runs `mutate -base origin/main`
```

[Getting started](https://spin-stack.github.io/go-tools/start/) has the two `versions.yaml` entries.

`versions`, `versions/upstream`, `versions/gate`, `provenance`, `attestgate`, `sbom`, `ghatrace` and
`trustroot` are also packages another repository imports; the rest is `internal/`.

Expand Down
40 changes: 40 additions & 0 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,46 @@ tasks:
cmds:
- go run ./cmd/ghatrace run -spans "${RUNNER_TEMP:-}"

release:build:
desc: Every command, for each platform a repository runs them on, as _output/release/go-tools_<os>_<arch>.tar.gz.
# What a repository's `go tool task` (cmd/task) unpacks onto Task's PATH, so that no repository
# compiles these commands or takes their modules. Stripped and without cgo: a program that runs
# on any machine of its platform, the same bytes from the same commit. The archive's members
# are sorted, owned by nobody and dated by the commit, for the same reason. cmd/task is not in
# it: it is the command a repository compiles, and its directory is first on Task's PATH, where
# a second task would stand in front of Task.
vars:
PLATFORMS: linux/amd64 linux/arm64 darwin/amd64 darwin/arm64
COMMANDS:
sh: go list ./cmd/... | grep -v '/cmd/task$' | tr '\n' ' '
cmds:
- rm -rf {{.OUT}}/release
- for: {var: PLATFORMS, split: ' '}
cmd: >-
CGO_ENABLED=0 GOOS={{index (splitList "/" .ITEM) 0}} GOARCH={{index (splitList "/" .ITEM) 1}}
go build -trimpath -ldflags='-s -w' -o {{.OUT}}/release/{{replace "/" "_" .ITEM}}/ {{.COMMANDS}}
- for: {var: PLATFORMS, split: ' '}
cmd: >-
tar --sort=name --owner=0 --group=0 --numeric-owner --mtime="@$(git show -s --format=%ct HEAD)"
-C {{.OUT}}/release/{{replace "/" "_" .ITEM}} -czf {{.OUT}}/release/go-tools_{{replace "/" "_" .ITEM}}.tar.gz .

release:publish:
desc: Publish _output/release's archives as the GitHub release bin-<commit time>, the version a versions.yaml pins (GH_TOKEN, GITHUB_SHA).
# Named by the commit's UTC time, which is also the time in the pseudo-version a go.mod requires
# this commit at: versions/gate holds a repository's go-tools entry to its go.mod's. A tag of
# that shape is no module version, so `go get` never takes it for one. A run that is retried
# publishes the release it made the first time, and fails rather than replace another's files.
cmds:
- |
set -euo pipefail
tag="bin-$(TZ=UTC git show -s --format=%cd --date=format-local:%Y%m%d.%H%M%S "${GITHUB_SHA}")"
if gh release view "$tag" >/dev/null 2>&1; then
echo "$tag is published already"
exit 0
fi
gh release create "$tag" {{.OUT}}/release/*.tar.gz --target "${GITHUB_SHA}" --title "$tag" \
--notes "Every go-tools command at ${GITHUB_SHA}, for a repository's versions.yaml: \`go tool versions bump go-tools\`."

site:
desc: Build the documentation site (site/, Astro Starlight) into site/dist; task site:dev serves it with live reload.
cmds:
Expand Down
6 changes: 3 additions & 3 deletions cmd/proven/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -382,10 +382,10 @@ func TestFlakesAreTheTestsThatPassedOnTheSameTree(t *testing.T) {
require.NoError(t, err)
assert.Contains(t, out, "1 failed tests in 4 runs; none flaky more than once\n")

// An artifact that is not a zip is said with its name.
f.archive = []byte("nope")
// An artifact uploaded unarchived - a run's trace.json - is its one file: read, not refused.
f.archive = []byte(`{"traceEvents":[]}`)
err = run(t.Context(), []string{"flakes", "-also", ""})
assert.ErrorContains(t, err, "run 6: artifact ci-lanes-short-6: the artifact is not a zip")
require.NoError(t, err)
}

// What is refused before the API is asked: no command, an unknown one, a flag it has not, no
Expand Down
102 changes: 102 additions & 0 deletions cmd/task/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// Command task is Task at the version a repository's versions.yaml pins, with go-tools' commands
// on its PATH: `go tool task lint` runs the released Task, and a Taskfile says `mutate` where it
// would say `go tool mutate`.
//
// go tool task [Task's arguments]
//
// A repository takes this one command in its go.mod instead of Task's and go-tools' own: compiling
// those is about 450 modules and a minute or more on a runner, for programs their authors already
// release. This one links versions.yaml's reader and a download, and it puts versions.yaml's `task`
// entry - and its `go-tools` entry, when it has one - in the user's cache, each held to its sha256
// and unpacked once by it (internal/toolcache), then becomes Task: the same process, so a signal,
// the terminal and the exit status are Task's.
package main

import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"syscall"

"github.com/spin-stack/go-tools/internal/toolcache"
"github.com/spin-stack/go-tools/versions"
)

func main() {
err := run(context.Background(), os.Args[1:], syscall.Exec) // mutate-exempt: the process's own arguments; run is what the test drives
fmt.Fprintln(os.Stderr, "task:", err) //nolint:errcheck // the error is the exit status's reason; stderr failing changes no status
os.Exit(2) // mutate-exempt: main is reached only when the exec failed, which no test makes it do; run is what the tests drive
}

// execFunc replaces the process with argv[0]'s program: syscall.Exec, and a recorder in a test.
type execFunc func(path string, argv []string, env []string) error

// run finds the repository's versions.yaml, installs what it pins, and execs Task with args. It
// returns only when that fails: on success the process is Task's.
func run(ctx context.Context, args []string, exec execFunc) error {
wd, err := os.Getwd()
if err != nil {
return err
}
file, err := find(wd)
if err != nil {
return err
}
v, err := versions.Load(file)
if err != nil {
return err
}
cache, err := os.UserCacheDir()
if err != nil {
return err
}
root := filepath.Join(cache, "spin-stack", "tools")
task, err := v.Get("task")
if err != nil {
return fmt.Errorf("%w: `go tool task` runs the Task it pins", err)
}
taskDir, err := toolcache.Install(ctx, task, root)
if err != nil {
return err
}
path := os.Getenv("PATH")
if tools, err := v.Get("go-tools"); err == nil {
dir, err := toolcache.Install(ctx, tools, root)
if err != nil {
return err
}
path = dir + string(os.PathListSeparator) + path
}
bin := filepath.Join(taskDir, "task")
return exec(bin, append([]string{bin}, args...), withPath(os.Environ(), path))
}

// withPath is env with PATH set to path: replaced where it is, since a program that reads the
// environment from the front would find a second PATH appended after the first only by luck.
func withPath(env []string, path string) []string {
var out []string
for _, kv := range env {
if !strings.HasPrefix(kv, "PATH=") {
out = append(out, kv)
}
}
return append(out, "PATH="+path)
}

// find is versions.yaml in dir or the nearest directory above it, as Task finds its Taskfile.
func find(dir string) (string, error) {
for {
p := filepath.Join(dir, versions.File)
if _, err := os.Stat(p); err == nil {
return p, nil
}
parent := filepath.Dir(dir)
if parent == dir {
return "", errors.New("no versions.yaml here or above: `go tool task` runs the Task a repository's versions.yaml pins")
}
dir = parent
}
}
Loading
Loading