Skip to content
Open
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
54 changes: 54 additions & 0 deletions .github/safepush.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
{
"schemaVersion": "cloudring.safepush.github/v1",
"repository": "opencloudtech/CloudRING",
"repositoryId": 1288130713,
"workflows": [
{
"path": ".github/workflows/ci.yml",
"jobs": [
"docscheck",
"go-build-module-integrity",
"go-test",
"go-test-race",
"go-vet",
"ocs-conformance",
"ocs-validate",
"platform-manifests",
"postgres-integration",
"source-safety",
"windows-go-test"
]
},
{
"path": ".github/workflows/cla-dco.yml",
"jobs": [
"cla",
"dco"
]
},
{
"path": ".github/workflows/license.yml",
"jobs": [
"license-check"
]
},
{
"path": ".github/workflows/security.yml",
"jobs": [
"codeql",
"gosec",
"govulncheck",
"secret-scan"
]
},
{
"path": ".github/workflows/supply-chain.yml",
"jobs": [
"supply-chain-policy"
]
}
],
"excludedWorkflows": [
".github/workflows/release-provenance.yml"
]
}
130 changes: 130 additions & 0 deletions cmd/cloudring-safepush/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright (C) Iurii Trukhin.

package main

import (
"context"
"errors"
"flag"
"fmt"
"io"
"net/http"
"os"
"os/signal"
"time"

"github.com/opencloudtech/CloudRING/pkg/safepush"
)

type githubVerifier func(context.Context, *http.Client, string, safepush.GitHubPolicy, safepush.GitHubCandidate) error
type githubPolicyReader func(context.Context, *http.Client, string, string, string) (safepush.GitHubPolicy, error)

func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
os.Exit(run(ctx, os.Args[1:], os.Getenv, os.Stdout, os.Stderr, safepush.ReadGitHubPolicy, safepush.VerifyGitHub))
}

// The native required workflow owns these arguments. There is deliberately no
// caller-supplied check-results file, success flag, API host, or policy override.
func run(ctx context.Context, args []string, getenv func(string) string, stdout, stderr io.Writer, read githubPolicyReader, verify githubVerifier) int {
if len(args) == 0 || args[0] != "github" {
fmt.Fprintln(stderr, "usage: cloudring-safepush github --repository OWNER/REPO --pr NUMBER --head SHA --base SHA --branch main|master")
return 2
}
flags := flag.NewFlagSet("github", flag.ContinueOnError)
flags.SetOutput(io.Discard)
repository := flags.String("repository", "", "target GitHub repository")
pr := flags.Int("pr", 0, "pull request number")
head := flags.String("head", "", "exact proposed commit")
base := flags.String("base", "", "exact accepted target commit")
branch := flags.String("branch", "", "protected target branch")
timeout := flags.Duration("timeout", 20*time.Minute, "bounded wait for native CI, at most 30 minutes")
if flags.Parse(args[1:]) != nil || flags.NArg() != 0 || *repository == "" || *pr <= 0 ||
*head == "" || *base == "" || (*branch != "main" && *branch != "master") ||
*timeout <= 0 || *timeout > 30*time.Minute {
fmt.Fprintln(stderr, "SafePush request is invalid")
return 2
}
candidate := safepush.GitHubCandidate{PullRequest: *pr, HeadSHA: *head, BaseSHA: *base, TargetBranch: *branch}
if safepush.ValidateGitHubCandidate(*repository, candidate) != nil {
fmt.Fprintln(stderr, "SafePush request is invalid")
return 2
}
bearer := getenv("GITHUB_TOKEN")
if bearer == "" {
fmt.Fprintln(stderr, "SafePush requires the workflow's read-only GITHUB_TOKEN")
return 2
}
ctx, cancel := context.WithTimeout(ctx, *timeout)
defer cancel()
client := &http.Client{Timeout: 30 * time.Second}
policy, err := read(ctx, client, bearer, *repository, *base)
if err != nil {
fmt.Fprintln(stderr, "SafePush blocked:", safeDiagnostic(err))
return 1
}
if policy.Repository != *repository {
fmt.Fprintln(stderr, "SafePush blocked: accepted policy belongs to another repository")
return 1
}
err = waitForChecks(ctx, 30*time.Second, func() error { return verify(ctx, client, bearer, policy, candidate) })
if err != nil {
fmt.Fprintln(stderr, "SafePush blocked:", safeDiagnostic(err))
return 1
}
if _, err := fmt.Fprintln(stdout, "SafePush: the exact required CI checks passed for the current candidate and base"); err != nil {
return 1
}
return 0
}

func safeDiagnostic(err error) string {
switch {
case errors.Is(err, context.Canceled):
return "request canceled"
case errors.Is(err, context.DeadlineExceeded):
return "verification deadline exceeded"
case errors.Is(err, safepush.ErrPolicy):
return "accepted repository policy is invalid"
case errors.Is(err, safepush.ErrGitHubAPI):
return "GitHub API is unavailable or returned an invalid response"
case errors.Is(err, safepush.ErrGitHubSource):
return "workflow or policy source does not match the accepted revision"
case errors.Is(err, safepush.ErrGitHubState):
return "pull request or protected target is stale or changed during verification"
case errors.Is(err, safepush.ErrGitHubRun):
return "CI run, job, or step provenance is invalid"
case errors.Is(err, safepush.ErrMissingCheck):
return "a required CI job is missing"
case errors.Is(err, safepush.ErrUnexpectedCheck), errors.Is(err, safepush.ErrDuplicateObservation):
return "the CI job set is unexpected or ambiguous"
case errors.Is(err, safepush.ErrCheckNotSuccessful):
return "a required CI job did not complete successfully"
default:
return "verification failed"
}
}

func waitForChecks(ctx context.Context, interval time.Duration, verify func() error) error {
for {
if err := ctx.Err(); err != nil {
return err
}
err := verify()
if contextErr := ctx.Err(); contextErr != nil {
return contextErr
}
if !errors.Is(err, safepush.ErrGitHubPending) {
return err
}
timer := time.NewTimer(interval)
select {
case <-ctx.Done():
timer.Stop()
return ctx.Err()
case <-timer.C:
}
}
}
90 changes: 90 additions & 0 deletions cmd/cloudring-safepush/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright (C) Iurii Trukhin.

package main

import (
"bytes"
"context"
"errors"
"net/http"
"strings"
"testing"
"time"

"github.com/opencloudtech/CloudRING/pkg/safepush"
)

func TestPendingIsTheOnlyRetriableAdmissionResult(t *testing.T) {
for _, terminal := range []error{nil, safepush.ErrCheckNotSuccessful, safepush.ErrPolicy, errors.New("unavailable")} {
calls := 0
err := waitForChecks(context.Background(), time.Nanosecond, func() error {
calls++
if calls == 1 {
return safepush.ErrGitHubPending
}
return terminal
})
if calls != 2 || !errors.Is(err, terminal) {
t.Fatalf("calls=%d error=%v", calls, err)
}
}
}

func TestCancellationStopsPendingAdmission(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
calls := 0
err := waitForChecks(ctx, time.Hour, func() error { calls++; cancel(); return safepush.ErrGitHubPending })
if !errors.Is(err, context.Canceled) || calls != 1 {
t.Fatalf("calls=%d error=%v", calls, err)
}
}

func TestSuccessAfterCancellationIsNotAccepted(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
err := waitForChecks(ctx, time.Nanosecond, func() error { cancel(); return nil })
if !errors.Is(err, context.Canceled) {
t.Fatalf("late success was accepted: %v", err)
}
}

func TestCommandDoesNotEchoSensitiveProviderErrors(t *testing.T) {
args := []string{"github", "--repository", "synthetic/project", "--pr", "1", "--head", strings.Repeat("a", 40), "--base", strings.Repeat("b", 40), "--branch", "main"}
marker := "synthetic-confidential-marker"
read := func(context.Context, *http.Client, string, string, string) (safepush.GitHubPolicy, error) {
return safepush.GitHubPolicy{Repository: "synthetic/project"}, nil
}
for _, failure := range []error{nil, errors.New(marker)} {
var out, errout bytes.Buffer
code := run(context.Background(), args, func(string) string { return marker }, &out, &errout, read,
func(context.Context, *http.Client, string, safepush.GitHubPolicy, safepush.GitHubCandidate) error {
return failure
})
if (code == 0) != (failure == nil) {
t.Fatalf("unexpected exit %d", code)
}
if strings.Contains(out.String()+errout.String(), marker) {
t.Fatal("provider error leaked sensitive input")
}
}
}

func TestInvalidInvocationMakesNoProviderCall(t *testing.T) {
for _, args := range [][]string{
{}, {"github", "--trusted=true"},
{"github", "--repository", "synthetic/project", "--pr", "1", "--head", "a", "--base", "b", "--branch", "feature"},
{"github", "--repository", "synthetic/project", "--pr", "1", "--head", "a", "--base", "b", "--branch", "main", "--timeout", "31m"},
{"github", "--repository", "synthetic/project", "--pr", "1", "--head", "a", "--base", "main", "--branch", "main"},
{"github", "--repository", "synthetic/../project", "--pr", "1", "--head", strings.Repeat("a", 40), "--base", strings.Repeat("b", 40), "--branch", "main"},
{"github", "--repository", "synthetic/project", "--pr", "1", "--head", strings.Repeat("0", 40), "--base", strings.Repeat("b", 40), "--branch", "main"},
} {
var out bytes.Buffer
read := func(context.Context, *http.Client, string, string, string) (safepush.GitHubPolicy, error) {
t.Fatal("provider called for invalid request")
return safepush.GitHubPolicy{}, nil
}
if code := run(context.Background(), args, func(string) string { return "synthetic-test-value" }, &out, &out, read, nil); code != 2 {
t.Fatalf("exit=%d", code)
}
}
}
51 changes: 25 additions & 26 deletions docs/ci-checks.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
# CloudRING CI Checks

Public CI for a clean clone runs these read-only checks on the latest security
patch of the minimum supported Go release (1.25) and runs unit tests again on
the current supported release (1.26):
Public CI for a clean clone runs these read-only checks with the workflow's
pinned Go toolchain, currently Go 1.26.9:

```bash
go mod download
Expand Down Expand Up @@ -31,9 +30,9 @@ The public CI contract covers these checks:

| Check | Contract |
| --- | --- |
| Go tests | The public module must pass tests on supported minimum/current Go releases, plus race, vet, read-only module graph, and build checks. |
| Go tests | The complete public module must pass tests with the pinned CI toolchain, plus race, vet, read-only module graph, and build checks. |
| PostgreSQL integration | Transactional-state CAS, migrations, concurrent writers, and the public runtime/session journey must pass against real digest-pinned PostgreSQL. The pinned Linux test container also exercises interrupted guest database directory setup and ownership rejection as UID 0; it asserts that identity before running these filesystem tests. |
| Windows | The same unit suite is run on `windows-latest` as a portability signal. Native Windows support is not a release-readiness blocker for the current goal. |
| Windows | The same unit suite runs on `windows-latest` and is included in the SafePush pre-merge policy. A passing suite does not establish native Windows release readiness. |
| OCS validation | Every shipped connector package selected by the shared CI package list must pass `go run ./cmd/ocsctl validate`. |
| OCS conformance | The same exact shipped connector packages must pass `go run ./cmd/ocsctl conformance`; validation cannot be green for an artifact that CI omits from conformance. |
| Synthetic reference image | The digest-pinned `Containerfile` must build and its local mock-provider self-check must pass. |
Expand Down Expand Up @@ -66,27 +65,27 @@ runtime image, credential context, conditional skip, and `continue-on-error`.
Changing a reviewed workflow requires changing its recorded digest in the same
review.

This repository check is defense in depth, not a self-authenticating policy: a
pull request controls the workflow revision that evaluates that pull request.
The server-side trust control for external contributors is protected `main`
with an up-to-date branch, one approving owner review, stale-review dismissal,
last-push approval, required conversations and checks, and no force-push or
branch deletion. The project founder and lead maintainer, `@trukhinyuri`, is
the final acceptance authority. Contributor-authored changes require that
owner review; automated or AI-assisted review does not replace the founder's
decision.

GitHub does not let a pull-request author submit the approval required by the
same branch rule. While the repository has a single founder/administrator, a
founder-authored pull request may therefore use the administrator merge path
after a recorded founder review of the exact head SHA. This narrow owner path
still requires every configured status check to succeed on the current head,
an up-to-date base, resolved conversations, source-safety approval, and a
post-merge `main` verification. It does not authorize direct pushes, skipped
checks, force-pushes, branch deletion, or bypasses for other contributors.
Adding another administrator changes this trust assumption and requires an
explicit governance and branch-protection review before that access is
granted.
This repository check is defense in depth: a pull request controls the
workflow revision that evaluates that pull request. The separate
[SafePush verifier and deployment contract](safepush.md) bind CI observations
to the accepted policy, workflow sources, repository, pull request and commit.
The verifier must run from trusted immutable source and be required by the
hosting platform. Its presence in the repository does not enable that control.

The required acceptance policy is an up-to-date protected target, every
required test passing and an owner-approved merge. Both `main` and `master`
must be covered; ordinary working branches remain unrestricted. The project
founder and lead maintainer, `@trukhinyuri`, is the final acceptance authority.
Automated or AI-assisted review does not replace that decision. Native review
rules must dismiss stale approvals and prevent unauthorized dismissal.

Only the explicitly designated owner may bypass test and review requirements;
future delegates require an explicit policy change. Administrator or
Maintainer status alone must not grant that exception. This owner path also
handles the platform's prohibition on approving one's own pull request.
Force-push and deletion controls remain separate from the test/review
exception. The actual installed rules and both ordinary and bypass behavior
must be read back and tested before claiming SafePush is enabled.

## Release provenance

Expand Down
Loading
Loading