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
13 changes: 11 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,23 @@ on:
branches:
- main

# Default to read-only. Individual jobs may grant additional scopes if needed.
permissions:
contents: read

jobs:
check:
runs-on: ubuntu-latest

permissions:
contents: read

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
persist-credentials: false

- uses: actions/setup-go@v5
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
with:
go-version-file: go.mod

Expand Down
94 changes: 78 additions & 16 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,18 @@ on:
tags:
- "v*"

permissions:
contents: write
# Deny by default at the workflow level. Each job opts into the smallest scope
# it needs (read for source, write only for the publish job that mints a
# release and signs blobs via OIDC).
permissions: {}

jobs:
build-release:
runs-on: ubuntu-latest

permissions:
contents: read

strategy:
fail-fast: false
matrix:
Expand All @@ -37,31 +43,57 @@ jobs:
binary_name: git-real.exe

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
fetch-depth: 0
persist-credentials: false

- uses: actions/setup-go@v5
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
with:
go-version-file: go.mod
# Disable Go module/build cache: a poisoned cache shared across
# workflows could otherwise influence the binaries we ship.
cache: false

- run: go mod download

- name: Build archive
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
BINARY_NAME: ${{ matrix.binary_name }}
ARCHIVE_EXT: ${{ matrix.archive_ext }}
run: |
set -euo pipefail
artifact_name="git-real_${{ matrix.goos }}_${{ matrix.goarch }}"
archive_name="${artifact_name}.${{ matrix.archive_ext }}"
artifact_name="git-real_${GOOS}_${GOARCH}"
archive_name="${artifact_name}.${ARCHIVE_EXT}"

mkdir -p "dist/${artifact_name}"

GOOS="${{ matrix.goos }}" GOARCH="${{ matrix.goarch }}" go build -o "dist/${artifact_name}/${{ matrix.binary_name }}" ./cmd/git-real
version="$(git describe --tags --always --dirty)"
commit="$(git rev-parse HEAD)"
date="$(git log -1 --format=%cI)"

# Pin the build timestamp to the tag commit's author date so that
# rebuilding from the same source produces a byte-identical binary.
export SOURCE_DATE_EPOCH="$(git log -1 --format=%ct)"

if [ "${{ matrix.archive_ext }}" = "zip" ]; then
zip -j "dist/${archive_name}" "dist/${artifact_name}/${{ matrix.binary_name }}"
go build \
-trimpath \
-buildvcs=true \
-ldflags="-s -w -X main.version=${version} -X main.commit=${commit} -X main.date=${date}" \
-o "dist/${artifact_name}/${BINARY_NAME}" \
./cmd/git-real

if [ "${ARCHIVE_EXT}" = "zip" ]; then
(cd dist && zip -j "${archive_name}" "${artifact_name}/${BINARY_NAME}")
else
tar -C dist -czf "dist/${archive_name}" "${artifact_name}"
tar -C dist --sort=name --owner=0 --group=0 --numeric-owner \
--mtime="@${SOURCE_DATE_EPOCH}" \
-czf "dist/${archive_name}" "${artifact_name}"
fi

- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: release-${{ matrix.goos }}-${{ matrix.goarch }}
path: dist/*.*
Expand All @@ -71,8 +103,12 @@ jobs:
runs-on: ubuntu-latest
needs: build-release

permissions:
contents: write # required to create the GitHub Release
id-token: write # required for cosign keyless signing via OIDC

steps:
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
path: dist
pattern: release-*
Expand All @@ -81,8 +117,34 @@ jobs:
- name: Generate checksums
run: |
set -euo pipefail
sha256sum dist/* > dist/SHA256SUMS
(cd dist && sha256sum git-real_*.tar.gz git-real_*.zip > SHA256SUMS)

- uses: softprops/action-gh-release@v2
with:
files: dist/*
- uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3.9.1

- name: Sign checksums with cosign (keyless OIDC)
env:
COSIGN_EXPERIMENTAL: "1"
run: |
set -euo pipefail
cosign sign-blob \
--yes \
--output-signature dist/SHA256SUMS.sig \
--output-certificate dist/SHA256SUMS.pem \
dist/SHA256SUMS

- name: Publish release
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
gh release create "${TAG}" \
--verify-tag \
--title "${TAG}" \
--generate-notes \
dist/git-real_*.tar.gz \
dist/git-real_*.zip \
dist/SHA256SUMS \
dist/SHA256SUMS.sig \
dist/SHA256SUMS.pem
13 changes: 12 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,21 @@ export GOMODCACHE ?= $(CACHE_DIR)/gomod
export GOPATH ?= $(CACHE_DIR)/gopath
export XDG_CACHE_HOME ?= $(CACHE_DIR)/xdg

# Reproducible-build inputs. Override on the command line for releases:
# make build VERSION=v1.2.3 COMMIT=$(git rev-parse HEAD) DATE=$(git log -1 --format=%cI)
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
COMMIT ?= $(shell git rev-parse HEAD 2>/dev/null || echo none)
DATE ?= $(shell git log -1 --format=%cI 2>/dev/null || echo unknown)

LDFLAGS := -s -w \
-X main.version=$(VERSION) \
-X main.commit=$(COMMIT) \
-X main.date=$(DATE)

.PHONY: build fmt fmt-check lint typecheck deadcode test coverage check

build:
$(GO) build -o git-real ./cmd/git-real
$(GO) build -trimpath -buildvcs=true -ldflags='$(LDFLAGS)' -o git-real ./cmd/git-real

fmt:
$(GO) fmt ./...
Expand Down
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ GitReal is intentionally conservative:
- Before any reset, GitReal stores the current `HEAD` under `refs/gitreal/backups/...`.
- `git real rescue restore <ref>` also backs up the current `HEAD` before restoring a backup.
- Dirty worktree changes are stashed and then restored when possible.
- `Ctrl-C` (SIGINT or SIGTERM) is honored at any point during a challenge: if you cancel
before the deadline, no penalty is applied.

If you want real enforcement for the current repository:

Expand Down Expand Up @@ -170,6 +172,33 @@ Release archives are published as:
- `git-real_linux_arm64.tar.gz`
- `git-real_windows_amd64.zip`
- `SHA256SUMS`
- `SHA256SUMS.sig` (cosign keyless signature)
- `SHA256SUMS.pem` (cosign certificate)

### Verifying a release

Releases are built with `-trimpath` and a pinned `SOURCE_DATE_EPOCH` so the
binaries are reproducible from the tagged commit. The `SHA256SUMS` file is
signed with [cosign](https://github.com/sigstore/cosign) keyless mode, tied to
the GitHub Actions OIDC identity for this repository.

```bash
cosign verify-blob \
--certificate SHA256SUMS.pem \
--signature SHA256SUMS.sig \
--certificate-identity-regexp '^https://github.com/watany-dev/gitreal/' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
SHA256SUMS

sha256sum --check SHA256SUMS
```

You can confirm a downloaded binary matches the published source revision with:

```bash
git real --version
# git-real v0.x.y (<commit>, built <iso8601-date>)
```

## More Detail

Expand Down
67 changes: 66 additions & 1 deletion cmd/git-real/e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ package main

import (
"bytes"
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)

func TestMainEndToEnd(t *testing.T) {
Expand All @@ -19,6 +21,7 @@ func TestMainEndToEnd(t *testing.T) {
runGit(t, repoDir, "checkout", "-b", "main")
runGit(t, repoDir, "config", "user.name", "GitReal Test")
runGit(t, repoDir, "config", "user.email", "test@example.com")
runGit(t, repoDir, "config", "commit.gpgsign", "false")

writeFile(t, filepath.Join(repoDir, "file.txt"), "base\n")
runGit(t, repoDir, "add", "file.txt")
Expand Down Expand Up @@ -109,10 +112,72 @@ func runMain(t *testing.T, dir string, args ...string) (string, string, int) {

stdout := new(bytes.Buffer)
stderr := new(bytes.Buffer)
exitCode := Main(args, stdout, stderr)
exitCode := Main(context.Background(), args, stdout, stderr)
return stdout.String(), stderr.String(), exitCode
}

func TestMainOnceCancelledByContext(t *testing.T) {
t.Parallel()

workDir := t.TempDir()
originDir := filepath.Join(workDir, "origin.git")
repoDir := filepath.Join(workDir, "repo")

runGit(t, workDir, "init", "--bare", originDir)
runGit(t, workDir, "clone", originDir, repoDir)
runGit(t, repoDir, "checkout", "-b", "main")
runGit(t, repoDir, "config", "user.name", "GitReal Test")
runGit(t, repoDir, "config", "user.email", "test@example.com")
runGit(t, repoDir, "config", "commit.gpgsign", "false")

writeFile(t, filepath.Join(repoDir, "file.txt"), "base\n")
runGit(t, repoDir, "add", "file.txt")
runGit(t, repoDir, "commit", "-m", "base")
runGit(t, repoDir, "push", "-u", "origin", "HEAD")

previousDir, err := os.Getwd()
if err != nil {
t.Fatalf("Getwd() error = %v", err)
}
if err := os.Chdir(repoDir); err != nil {
t.Fatalf("Chdir() error = %v", err)
}
defer func() { _ = os.Chdir(previousDir) }()

if exit := Main(context.Background(), []string{"init"}, new(bytes.Buffer), new(bytes.Buffer)); exit != 0 {
t.Fatalf("init exit = %d, want 0", exit)
}
if exit := Main(context.Background(), []string{"arm"}, new(bytes.Buffer), new(bytes.Buffer)); exit != 0 {
t.Fatalf("arm exit = %d, want 0", exit)
}

writeFile(t, filepath.Join(repoDir, "file.txt"), "base\nlocal\n")
runGit(t, repoDir, "commit", "-am", "local")

ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(50 * time.Millisecond)
cancel()
}()

stdout := new(bytes.Buffer)
stderr := new(bytes.Buffer)
exitCode := Main(ctx, []string{"once", "--grace-seconds=30"}, stdout, stderr)
if exitCode != 0 {
t.Fatalf("once after cancel exit = %d, stderr = %q", exitCode, stderr.String())
}
if !strings.Contains(stdout.String(), "interrupted") {
t.Fatalf("stdout = %q, want interrupt notice", stdout.String())
}
if ahead := strings.TrimSpace(runGitOutput(t, repoDir, "rev-list", "--count", "@{u}..HEAD")); ahead != "1" {
t.Fatalf("ahead after cancel = %q, want 1 (no penalty)", ahead)
}
out := runGitOutput(t, repoDir, "for-each-ref", "refs/gitreal/backups/")
if strings.TrimSpace(out) != "" {
t.Fatalf("backup refs created on cancel: %q", out)
}
}

func runGit(t *testing.T, dir string, args ...string) {
t.Helper()

Expand Down
30 changes: 27 additions & 3 deletions cmd/git-real/main.go
Original file line number Diff line number Diff line change
@@ -1,16 +1,40 @@
package main

import (
"context"
"io"
"os"
"os/signal"
"syscall"

"github.com/watany-dev/gitreal/internal/cli"
)

func Main(args []string, stdout, stderr io.Writer) int {
return cli.Run(args, stdout, stderr)
// Build-time variables populated via -ldflags by the Makefile and the release
// workflow. They are surfaced via `git real --version` so users can verify that
// a binary corresponds to a specific source revision and SHA256SUMS entry.
var (
version = "dev"
commit = "none"
date = "unknown"
)

func Main(ctx context.Context, args []string, stdout, stderr io.Writer) int {
for _, arg := range args {
if arg == "--version" || arg == "-V" {
printVersion(stdout)
return 0
}
}
return cli.Run(ctx, args, stdout, stderr)
}

func printVersion(w io.Writer) {
_, _ = io.WriteString(w, "git-real "+version+" ("+commit+", built "+date+")\n")
}

func main() {
os.Exit(Main(os.Args[1:], os.Stdout, os.Stderr))
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
os.Exit(Main(ctx, os.Args[1:], os.Stdout, os.Stderr))
}
Loading
Loading