From a5ee540d1d612c5975ac7ecad7228b864f674a6f Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Tue, 11 Aug 2026 05:53:42 +0700 Subject: [PATCH 1/7] docs: design GitLab OSS release pipeline --- docs/gitlab-oss-release-design.md | 214 ++++++++++++++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 docs/gitlab-oss-release-design.md diff --git a/docs/gitlab-oss-release-design.md b/docs/gitlab-oss-release-design.md new file mode 100644 index 0000000..4682755 --- /dev/null +++ b/docs/gitlab-oss-release-design.md @@ -0,0 +1,214 @@ +# GitLab-to-Alicloud OSS release design + +Status: approved design for implementation on 2026-08-11. This document does +not approve creating a tag or publishing `v0.1.0`. + +## Purpose + +Goto GitLab is the trusted build and publication system for Midtrans CLI while +the GitHub repository remains the canonical source repository. GitLab builds, +verifies, and uploads public release artifacts to Alicloud OSS. After an +operator independently verifies those public objects, the operator creates the +GitHub Release and adds the OSS URLs manually. + +The first release is checksummed but unsigned. Signature generation and +verification are explicitly deferred until a later release with an approved +signing identity. + +## Repository and trust boundaries + +- GitHub `veritrans/midtrans-cli` is the canonical source and public release + metadata location. +- Goto GitLab `go-merchants/payment/midtrans-cli` is a manually maintained + release copy. It is not a development fork or a bidirectional mirror. +- Operators manually push the approved `main` commit and annotated release tag + to Goto GitLab. +- GitLab runners execute verification and packaging. Only a protected manual + job may upload public objects. +- Alicloud OSS is the merchant-facing binary distribution channel. +- No GitHub token, GitHub Actions workflow, automatic GitHub mirroring, or + automatic GitHub Release publication is required by this flow. + +## Release reference flow + +1. Confirm that the candidate commit is present on canonical GitHub `main`. +2. Push that exact commit to Goto GitLab `main` and obtain a green snapshot + pipeline. +3. After every non-CI release gate is approved, create an annotated semantic + version tag and push it to Goto GitLab. +4. The tag pipeline verifies and packages the exact tagged commit. +5. An authorized operator starts the protected `publish:oss` job. +6. GitLab uploads the artifacts and then independently downloads the public + objects to verify their checksums. +7. Push the same annotated tag, pointing to the same commit, to GitHub. +8. Create the GitHub Release manually and add the verified public OSS URLs. + +The tag pattern is `vMAJOR.MINOR.PATCH`. The first proposed tag is `v0.1.0`, +but this design does not authorize creating it. + +## Pipeline shape + +The pipeline has four stages: `validate`, `verify`, `package`, and `publish`. + +### Validate + +`validate:release-ref` enforces the permitted pipeline sources: + +- A push to `main` runs a non-publishing snapshot pipeline. +- A protected tag matching `vMAJOR.MINOR.PATCH` runs the release pipeline. +- Other branches, malformed tags, schedules, merge-request pipelines, and + other pipeline sources do not create release jobs. + +For a release tag, validation also confirms that the tagged commit is contained +in GitLab `main` and that the repository history required by GoReleaser is +available. + +### Verify + +`verify:release` runs `./tools/check_release.sh`. This retains the existing +release checks, including module tidiness, race-enabled tests, vet, +vulnerability scanning, full-history secret scanning, workflow linting, +installer atomicity and rollback, evaluation fixtures, source drift, build, +GoReleaser configuration validation, and repository policy checks. + +### Package + +`package:release` uses pinned GoReleaser and Syft versions. A `main` pipeline +creates a snapshot. A version-tag pipeline creates final-version artifacts +without publishing them to a source-control provider and without signing them. + +The job must produce and verify: + +- five platform archives; +- one source archive; +- six nonempty SPDX SBOM documents; +- `checksums.txt`; +- `artifacts.json`; and +- `release-manifest.json`. + +`./tools/check_release_artifacts.sh` verifies the GoReleaser output before it +can be passed to the publication job. GitLab retains the package output as job +artifacts for audit and troubleshooting. + +`release-manifest.json` records: + +- the release version and Git tag; +- the exact Git commit SHA; +- the GitLab project and pipeline identifiers; +- the artifact filename, type, size, SHA-256 digest, and public URL for every + published object; +- the UTC build time; and +- `signed: false` for `v0.1.0`. + +### Publish + +`publish:oss` is present only for a valid protected version tag. It is manual, +protected, and depends on successful validation, verification, packaging, and +artifact verification. + +The job adapts only the Alicloud publisher mechanism from One Payment +Dashboard's `publish_vite_app_template_alicloud` job: + +- image `asia.gcr.io/gopay-systems/cx-oss:latest` with an empty entrypoint; +- runner tag `gopaysh`; +- a GitLab ID token named `ALICLOUD_ID_TOKEN` with audience + `https://source.golabs.io`; +- `oss auth` and the generated `.oss-credentials`; and +- recursive upload with `ossutil cp`. + +It does not copy Vite, dashboard, deploy, CDN invalidation, environment, or +application-specific jobs. + +## Alicloud OSS layout + +The fixed public directory is: + +```text +al-mp-id-p-midtrans-cli +``` + +Each publication uses an immutable tag-and-pipeline path: + +```text +oss://$ALICLOUD_BUCKET/al-mp-id-p-midtrans-cli/releases/$CI_COMMIT_TAG/$CI_PIPELINE_ID/ +``` + +The corresponding public object URL is constructed from the protected +`OSS_PUBLIC_BASE_URL` GitLab variable. The expected shape is: + +```text +$OSS_PUBLIC_BASE_URL/al-mp-id-p-midtrans-cli/releases/$CI_COMMIT_TAG/$CI_PIPELINE_ID/$ARTIFACT_NAME +``` + +All release objects are uploaded first. `release-manifest.json` is uploaded +last and acts as the completeness marker. A release path without that manifest +must not be linked from GitHub or treated as complete. + +## GitLab variables + +The publication job maps the established inherited production variables to +the names consumed by the `cx-oss` image: + +- `ALICLOUD_PRD_BUCKET` to `ALICLOUD_BUCKET`; +- `ALICLOUD_PRD_ACCOUNT_ID` to `ALICLOUD_ACCOUNT_ID`; +- `ALICLOUD_PRD_ROLE_NAME` to `ALICLOUD_ROLE_NAME`; and +- `ALIBABA_PRD_CLOUD_REGION` to `ALIBABA_CLOUD_REGION`. + +`OSS_PUBLIC_BASE_URL` is a protected variable used only to construct and test +public download URLs. `BUCKET_PUBLIC_DIR` is fixed in the pipeline to +`al-mp-id-p-midtrans-cli`. + +The job must not print credentials, persist `.oss-credentials`, or include +credentials in GitLab artifacts. The OIDC token and derived credentials exist +only for the publication job. + +## Failure and recovery behavior + +- Verification or packaging failure prevents publication. +- A missing required variable fails before `oss auth` or upload begins. +- A partial upload has no completeness manifest and is not releasable. +- Retrying the same publication job uses the same tag and pipeline path and the + same verified GitLab artifacts. +- A new pipeline for the same tag receives a different pipeline path and cannot + overwrite the previous build. +- After upload, the job downloads the public manifest, checksum file, and + archives into a clean temporary directory and verifies the checksums. +- Public-download verification failure leaves the pipeline failed and prevents + the operator from creating the GitHub Release. +- GitHub publication is always a separate operator action after GitLab and OSS + verification succeed. + +## Documentation changes + +Implementation updates the public documentation so it matches the selected +release system: + +- replace the required green GitHub Actions gate with a green Goto GitLab + release pipeline on the reviewed commit and tag; +- document OSS as the v0.1.0 artifact host and GitHub Releases as metadata and + discovery; +- replace GitHub-OIDC Cosign commands with SHA-256 checksum verification; +- state plainly that v0.1.0 artifacts are checksummed but unsigned; and +- keep the controlled 18-run evaluation, Agent Skill coordination, stakeholder + approval, exact-commit approval, Sandbox-only boundary, and post-publication + smoke test as release blockers. + +## Acceptance criteria + +Implementation is complete when all of the following are true: + +- GitLab CI configuration passes the Goto GitLab CI lint API. +- A `main` pipeline can verify and package a snapshot without exposing an OSS + publication job. +- A protected version tag can verify and package final-version artifacts and + exposes exactly one manual OSS publication job. +- The package contains the expected archives, SBOMs, checksum manifest, and + release manifest and contains no excluded specification paths. +- The OSS job uses GitLab OIDC and the established `cx-oss` publisher without + storing long-lived Alicloud credentials in the repository. +- The public release manifest is uploaded last and all public downloads pass + checksum verification. +- The GitHub repository contains no claim that GitHub Actions, signatures, the + controlled evaluation, stakeholder approvals, or the public release have + completed when they have not. + From bcc2661c166228120824dc44f94deb57dbb17448 Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Tue, 11 Aug 2026 06:14:59 +0700 Subject: [PATCH 2/7] chore: ignore local worktrees --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 4573fdd..c5962bc 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ /midtrans /.superpowers/ /docs/superpowers/ +/.worktrees/ **/__pycache__/ **/*.pyc /evaluations/fixtures/*/.state/ From 1ab7f13198d09fed861f8b14e957be448c346ae8 Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Tue, 11 Aug 2026 06:19:46 +0700 Subject: [PATCH 3/7] feat: generate OSS release manifests --- tools/release-manifest/main.go | 327 ++++++++++++++++++++++++++++ tools/release-manifest/main_test.go | 187 ++++++++++++++++ 2 files changed, 514 insertions(+) create mode 100644 tools/release-manifest/main.go create mode 100644 tools/release-manifest/main_test.go diff --git a/tools/release-manifest/main.go b/tools/release-manifest/main.go new file mode 100644 index 0000000..6ac5505 --- /dev/null +++ b/tools/release-manifest/main.go @@ -0,0 +1,327 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "net/url" + "os" + "path" + "path/filepath" + "regexp" + "sort" + "strings" + "time" +) + +var ( + versionPattern = regexp.MustCompile("^[0-9]+[.][0-9]+[.][0-9]+$") + commitPattern = regexp.MustCompile("^[0-9a-f]{40}$") + pipelineIDPattern = regexp.MustCompile("^[0-9]+$") +) + +type Config struct { + DistDir string + Output string + Version string + Tag string + Commit string + ProjectPath string + PipelineID string + PipelineURL string + PublicBaseURL string + PublicDir string + BuiltAt string +} + +type GitLabInfo struct { + ProjectPath string `json:"project_path"` + PipelineID string `json:"pipeline_id"` + PipelineURL string `json:"pipeline_url"` +} + +type Artifact struct { + Name string `json:"name"` + Type string `json:"type"` + Size int64 `json:"size"` + SHA256 string `json:"sha256"` + URL string `json:"url"` +} + +type Manifest struct { + SchemaVersion string `json:"schema_version"` + Version string `json:"version"` + GitTag string `json:"git_tag"` + GitCommit string `json:"git_commit"` + GitLab GitLabInfo `json:"gitlab"` + BuiltAt string `json:"built_at"` + Signed bool `json:"signed"` + Artifacts []Artifact `json:"artifacts"` +} + +type goreleaserArtifact struct { + Name string `json:"name"` + Path string `json:"path"` + Type string `json:"type"` +} + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run() error { + var cfg Config + flag.StringVar(&cfg.DistDir, "dist", "dist", "GoReleaser output directory") + flag.StringVar(&cfg.Output, "output", "dist/release-manifest.json", "release manifest output") + flag.StringVar(&cfg.Version, "version", "", "release version without v prefix") + flag.StringVar(&cfg.Tag, "tag", "", "annotated release tag") + flag.StringVar(&cfg.Commit, "commit", "", "release commit SHA") + flag.StringVar(&cfg.ProjectPath, "project-path", "", "GitLab project path") + flag.StringVar(&cfg.PipelineID, "pipeline-id", "", "GitLab pipeline ID") + flag.StringVar(&cfg.PipelineURL, "pipeline-url", "", "GitLab pipeline URL") + flag.StringVar(&cfg.PublicBaseURL, "public-base-url", "", "public OSS base URL") + flag.StringVar(&cfg.PublicDir, "public-dir", "", "public OSS directory") + flag.StringVar(&cfg.BuiltAt, "built-at", "", "RFC3339 build timestamp") + flag.Parse() + + manifest, err := buildManifest(cfg) + if err != nil { + return err + } + return writeManifest(cfg.Output, manifest) +} + +func buildManifest(cfg Config) (Manifest, error) { + publicBase, err := validateConfig(cfg) + if err != nil { + return Manifest{}, err + } + + distInfo, err := os.Lstat(cfg.DistDir) + if err != nil { + return Manifest{}, fmt.Errorf("inspect dist directory: %w", err) + } + if !distInfo.IsDir() || distInfo.Mode()&os.ModeSymlink != 0 { + return Manifest{}, errors.New("dist path must be a regular directory") + } + + records, err := readGoReleaserArtifacts(cfg.DistDir) + if err != nil { + return Manifest{}, err + } + + selected := make(map[string]string) + for _, record := range records { + if !isPublishedType(record.Type) { + continue + } + name, err := safeArtifactName(record) + if err != nil { + return Manifest{}, err + } + if _, exists := selected[name]; exists { + return Manifest{}, fmt.Errorf("duplicate artifact basename %q", name) + } + selected[name] = record.Type + } + if _, exists := selected["artifacts.json"]; exists { + return Manifest{}, errors.New("duplicate artifact basename \"artifacts.json\"") + } + selected["artifacts.json"] = "Metadata" + + artifacts := make([]Artifact, 0, len(selected)) + for name, artifactType := range selected { + filePath := filepath.Join(cfg.DistDir, name) + info, err := os.Lstat(filePath) + if err != nil { + return Manifest{}, fmt.Errorf("inspect artifact %s: %w", name, err) + } + if !info.Mode().IsRegular() { + return Manifest{}, fmt.Errorf("artifact %s must be a regular file", name) + } + digest, err := hashFile(filePath) + if err != nil { + return Manifest{}, fmt.Errorf("hash artifact %s: %w", name, err) + } + artifacts = append(artifacts, Artifact{ + Name: name, + Type: artifactType, + Size: info.Size(), + SHA256: digest, + URL: artifactURL(publicBase, cfg.PublicDir, cfg.Tag, cfg.PipelineID, name), + }) + } + sort.Slice(artifacts, func(i, j int) bool { + return artifacts[i].Name < artifacts[j].Name + }) + + return Manifest{ + SchemaVersion: "1.0", + Version: cfg.Version, + GitTag: cfg.Tag, + GitCommit: cfg.Commit, + GitLab: GitLabInfo{ + ProjectPath: cfg.ProjectPath, + PipelineID: cfg.PipelineID, + PipelineURL: cfg.PipelineURL, + }, + BuiltAt: cfg.BuiltAt, + Signed: false, + Artifacts: artifacts, + }, nil +} + +func validateConfig(cfg Config) (*url.URL, error) { + if !versionPattern.MatchString(cfg.Version) { + return nil, fmt.Errorf("version %q must match MAJOR.MINOR.PATCH", cfg.Version) + } + if cfg.Tag != "v"+cfg.Version { + return nil, fmt.Errorf("tag %q does not match version %q", cfg.Tag, cfg.Version) + } + if !commitPattern.MatchString(cfg.Commit) { + return nil, errors.New("commit must be a 40-character lowercase hexadecimal SHA") + } + if strings.TrimSpace(cfg.ProjectPath) == "" { + return nil, errors.New("project path is required") + } + if !pipelineIDPattern.MatchString(cfg.PipelineID) { + return nil, errors.New("pipeline ID must contain only digits") + } + if err := requireHTTPSURL("pipeline URL", cfg.PipelineURL); err != nil { + return nil, err + } + if _, err := time.Parse(time.RFC3339, cfg.BuiltAt); err != nil { + return nil, fmt.Errorf("built-at must be RFC3339: %w", err) + } + if !safeSegment(cfg.PublicDir) { + return nil, errors.New("public directory must be one safe path segment") + } + + publicBase, err := url.Parse(cfg.PublicBaseURL) + if err != nil { + return nil, fmt.Errorf("parse public base URL: %w", err) + } + if publicBase.Scheme != "https" || publicBase.Host == "" || + publicBase.User != nil || publicBase.RawQuery != "" || publicBase.Fragment != "" { + return nil, errors.New("public base URL must be an HTTPS URL without credentials, query, or fragment") + } + return publicBase, nil +} + +func requireHTTPSURL(label, raw string) error { + parsed, err := url.Parse(raw) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" { + return fmt.Errorf("%s must be an HTTPS URL", label) + } + return nil +} + +func safeSegment(value string) bool { + return value != "" && value != "." && value != ".." && + filepath.Base(value) == value && !strings.ContainsAny(value, "/\\") +} + +func readGoReleaserArtifacts(distDir string) ([]goreleaserArtifact, error) { + manifestPath := filepath.Join(distDir, "artifacts.json") + info, err := os.Lstat(manifestPath) + if err != nil { + return nil, fmt.Errorf("inspect artifacts.json: %w", err) + } + if !info.Mode().IsRegular() { + return nil, errors.New("artifacts.json must be a regular file") + } + data, err := os.ReadFile(manifestPath) + if err != nil { + return nil, fmt.Errorf("read artifacts.json: %w", err) + } + var records []goreleaserArtifact + if err := json.Unmarshal(data, &records); err != nil { + return nil, fmt.Errorf("parse artifacts.json: %w", err) + } + return records, nil +} + +func isPublishedType(artifactType string) bool { + switch artifactType { + case "Archive", "Source", "SBOM", "Checksum": + return true + default: + return false + } +} + +func safeArtifactName(record goreleaserArtifact) (string, error) { + cleaned := filepath.Clean(record.Path) + if record.Path == "" || filepath.IsAbs(cleaned) || cleaned == ".." || + strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("unsafe artifact path %q", record.Path) + } + name := filepath.Base(cleaned) + if !safeSegment(name) || record.Name != name { + return "", fmt.Errorf("unsafe artifact name %q for path %q", record.Name, record.Path) + } + return name, nil +} + +func hashFile(filePath string) (string, error) { + file, err := os.Open(filePath) + if err != nil { + return "", err + } + defer file.Close() + + hasher := sha256.New() + if _, err := io.Copy(hasher, file); err != nil { + return "", err + } + return hex.EncodeToString(hasher.Sum(nil)), nil +} + +func artifactURL(base *url.URL, publicDir, tag, pipelineID, name string) string { + copy := *base + copy.Path = path.Join(copy.Path, publicDir, "releases", tag, pipelineID, name) + return copy.String() +} + +func writeManifest(output string, manifest Manifest) error { + outputDir := filepath.Dir(output) + info, err := os.Lstat(outputDir) + if err != nil { + return fmt.Errorf("inspect output directory: %w", err) + } + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return errors.New("output directory must be a regular directory") + } + + temp, err := os.CreateTemp(outputDir, ".release-manifest-*.json") + if err != nil { + return fmt.Errorf("create temporary manifest: %w", err) + } + tempName := temp.Name() + defer os.Remove(tempName) + + encoder := json.NewEncoder(temp) + encoder.SetIndent("", " ") + if err := encoder.Encode(manifest); err != nil { + temp.Close() + return fmt.Errorf("encode release manifest: %w", err) + } + if err := temp.Chmod(0o644); err != nil { + temp.Close() + return fmt.Errorf("set release manifest permissions: %w", err) + } + if err := temp.Close(); err != nil { + return fmt.Errorf("close release manifest: %w", err) + } + if err := os.Rename(tempName, output); err != nil { + return fmt.Errorf("publish release manifest: %w", err) + } + return nil +} diff --git a/tools/release-manifest/main_test.go b/tools/release-manifest/main_test.go new file mode 100644 index 0000000..4bacc68 --- /dev/null +++ b/tools/release-manifest/main_test.go @@ -0,0 +1,187 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +type fixtureArtifact struct { + Name string `json:"name"` + Path string `json:"path"` + Type string `json:"type"` +} + +func writeFixture(t *testing.T, artifacts []fixtureArtifact, files map[string]string) string { + t.Helper() + + distDir := t.TempDir() + for name, content := range files { + path := filepath.Join(distDir, name) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + } + + data, err := json.Marshal(artifacts) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(distDir, "artifacts.json"), data, 0o600); err != nil { + t.Fatal(err) + } + return distDir +} + +func validConfig(distDir string) Config { + return Config{ + DistDir: distDir, + Output: filepath.Join(distDir, "release-manifest.json"), + Version: "0.1.0", + Tag: "v0.1.0", + Commit: strings.Repeat("a", 40), + ProjectPath: "go-merchants/payment/midtrans-cli", + PipelineID: "12345", + PipelineURL: "https://source.golabs.io/go-merchants/payment/midtrans-cli/-/pipelines/12345", + PublicBaseURL: "https://downloads.midtrans.com/base", + PublicDir: "al-mp-id-p-midtrans-cli", + BuiltAt: "2026-08-11T00:00:00Z", + } +} + +func TestBuildManifestInventoriesArtifactsDeterministically(t *testing.T) { + distDir := writeFixture(t, + []fixtureArtifact{ + {Name: "package.sbom.json", Path: "dist/package.sbom.json", Type: "SBOM"}, + {Name: "package.tar.gz", Path: "dist/package.tar.gz", Type: "Archive"}, + {Name: "checksums.txt", Path: "dist/checksums.txt", Type: "Checksum"}, + {Name: "ignored", Path: "dist/ignored", Type: "Binary"}, + }, + map[string]string{ + "package.sbom.json": `{"spdxVersion":"SPDX-2.3"}`, + "package.tar.gz": "archive", + "checksums.txt": "checksum content", + "ignored": "raw binary", + }, + ) + + got, err := buildManifest(validConfig(distDir)) + if err != nil { + t.Fatal(err) + } + + if got.SchemaVersion != "1.0" || got.Version != "0.1.0" || got.GitTag != "v0.1.0" { + t.Fatalf("unexpected identity: %#v", got) + } + if got.Signed { + t.Fatal("v0.1.0 manifest must be unsigned") + } + if got.GitLab.ProjectPath != "go-merchants/payment/midtrans-cli" || + got.GitLab.PipelineID != "12345" { + t.Fatalf("unexpected GitLab metadata: %#v", got.GitLab) + } + + wantNames := []string{"artifacts.json", "checksums.txt", "package.sbom.json", "package.tar.gz"} + if len(got.Artifacts) != len(wantNames) { + t.Fatalf("got %d artifacts, want %d: %#v", len(got.Artifacts), len(wantNames), got.Artifacts) + } + for index, want := range wantNames { + if got.Artifacts[index].Name != want { + t.Fatalf("artifact %d name = %q, want %q", index, got.Artifacts[index].Name, want) + } + if !strings.HasPrefix(got.Artifacts[index].URL, + "https://downloads.midtrans.com/base/al-mp-id-p-midtrans-cli/releases/v0.1.0/12345/") { + t.Fatalf("unexpected public URL: %s", got.Artifacts[index].URL) + } + } + + sum := sha256.Sum256([]byte("archive")) + if got.Artifacts[3].SHA256 != hex.EncodeToString(sum[:]) { + t.Fatalf("archive hash = %q, want %q", got.Artifacts[3].SHA256, hex.EncodeToString(sum[:])) + } + if got.Artifacts[3].Size != int64(len("archive")) { + t.Fatalf("archive size = %d", got.Artifacts[3].Size) + } +} + +func TestBuildManifestRejectsInvalidInputs(t *testing.T) { + tests := []struct { + name string + artifacts []fixtureArtifact + files map[string]string + mutate func(t *testing.T, distDir string, cfg *Config) + wantError string + }{ + { + name: "non HTTPS public base", + artifacts: []fixtureArtifact{ + {Name: "package.tar.gz", Path: "dist/package.tar.gz", Type: "Archive"}, + }, + files: map[string]string{"package.tar.gz": "archive"}, + mutate: func(_ *testing.T, _ string, cfg *Config) { + cfg.PublicBaseURL = "http://downloads.midtrans.com" + }, + wantError: "HTTPS", + }, + { + name: "missing referenced file", + artifacts: []fixtureArtifact{ + {Name: "missing.tar.gz", Path: "dist/missing.tar.gz", Type: "Archive"}, + }, + files: map[string]string{}, + wantError: "missing.tar.gz", + }, + { + name: "duplicate basename", + artifacts: []fixtureArtifact{ + {Name: "package.tar.gz", Path: "dist/package.tar.gz", Type: "Archive"}, + {Name: "package.tar.gz", Path: "dist/nested/package.tar.gz", Type: "Source"}, + }, + files: map[string]string{"package.tar.gz": "archive"}, + wantError: "duplicate", + }, + { + name: "path traversal", + artifacts: []fixtureArtifact{ + {Name: "escape.tar.gz", Path: "../escape.tar.gz", Type: "Archive"}, + }, + files: map[string]string{"escape.tar.gz": "archive"}, + wantError: "unsafe", + }, + { + name: "symlink", + artifacts: []fixtureArtifact{ + {Name: "package.tar.gz", Path: "dist/package.tar.gz", Type: "Archive"}, + }, + files: map[string]string{"target": "archive"}, + mutate: func(t *testing.T, distDir string, _ *Config) { + t.Helper() + if err := os.Symlink(filepath.Join(distDir, "target"), filepath.Join(distDir, "package.tar.gz")); err != nil { + t.Fatal(err) + } + }, + wantError: "regular file", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + distDir := writeFixture(t, test.artifacts, test.files) + cfg := validConfig(distDir) + if test.mutate != nil { + test.mutate(t, distDir, &cfg) + } + _, err := buildManifest(cfg) + if err == nil || !strings.Contains(err.Error(), test.wantError) { + t.Fatalf("error = %v, want substring %q", err, test.wantError) + } + }) + } +} From 0c979e2509eef6b53a8bfd711dce599899a999e5 Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Tue, 11 Aug 2026 06:24:18 +0700 Subject: [PATCH 4/7] ci: publish releases through GitLab and OSS --- .gitlab-ci.yml | 206 ++++++++++++++++++++++++++ tools/check_release.sh | 1 + tools/test-gitlab-release-pipeline.sh | 34 +++++ 3 files changed, 241 insertions(+) create mode 100644 .gitlab-ci.yml create mode 100755 tools/test-gitlab-release-pipeline.sh diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..c2ac34e --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,206 @@ +workflow: + rules: + - if: $CI_COMMIT_TAG + - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "main"' + - when: never + +stages: + - validate + - verify + - package + - publish + +variables: + GIT_DEPTH: "0" + BUCKET_PUBLIC_DIR: al-mp-id-p-midtrans-cli + GORELEASER_VERSION: v2.17.0 + SYFT_VERSION: v1.42.3 + +validate:release-ref: + stage: validate + image: golang:1.26.5 + script: + - | + set -eu + if [ -n "${CI_COMMIT_TAG:-}" ]; then + echo "$CI_COMMIT_TAG" | grep -Eq '^v[0-9]+[.][0-9]+[.][0-9]+$' + test "$(git cat-file -t "$CI_COMMIT_TAG")" = "tag" + git fetch origin main:refs/remotes/origin/main --tags --force + git merge-base --is-ancestor "$CI_COMMIT_SHA" origin/main + else + test "$CI_PIPELINE_SOURCE" = "push" + test "$CI_COMMIT_BRANCH" = "main" + fi + +verify:release: + stage: verify + image: golang:1.26.5 + needs: + - validate:release-ref + before_script: + - apt-get update -qq + - >- + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends + ca-certificates curl git jq nodejs npm python3 ripgrep ruby + - rm -rf /var/lib/apt/lists/* + script: + - ./tools/check_release.sh + +package:release: + stage: package + image: golang:1.26.5 + needs: + - validate:release-ref + - verify:release + before_script: + - apt-get update -qq + - >- + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends + ca-certificates git jq + - rm -rf /var/lib/apt/lists/* + script: + - | + set -eu + export PATH="$(go env GOPATH)/bin:$PATH" + go install "github.com/anchore/syft/cmd/syft@${SYFT_VERSION}" + + if [ -z "${CI_COMMIT_TAG:-}" ]; then + go run "github.com/goreleaser/goreleaser/v2@${GORELEASER_VERSION}" \ + release --snapshot --clean + ./tools/check_release_artifacts.sh + exit 0 + fi + + : "${OSS_PUBLIC_BASE_URL:?OSS_PUBLIC_BASE_URL is required for release tags}" + go run "github.com/goreleaser/goreleaser/v2@${GORELEASER_VERSION}" \ + release --clean --skip=publish + ./tools/check_release_artifacts.sh + go run ./tools/release-manifest \ + --dist dist \ + --output dist/release-manifest.json \ + --version "${CI_COMMIT_TAG#v}" \ + --tag "$CI_COMMIT_TAG" \ + --commit "$CI_COMMIT_SHA" \ + --project-path "$CI_PROJECT_PATH" \ + --pipeline-id "$CI_PIPELINE_ID" \ + --pipeline-url "$CI_PIPELINE_URL" \ + --public-base-url "$OSS_PUBLIC_BASE_URL" \ + --public-dir "$BUCKET_PUBLIC_DIR" \ + --built-at "$CI_PIPELINE_CREATED_AT" + + mkdir release-upload + jq -r '.artifacts[].name' dist/release-manifest.json | + while IFS= read -r artifact; do + test "$(basename "$artifact")" = "$artifact" + cp "dist/$artifact" "release-upload/$artifact" + done + cp dist/release-manifest.json release-upload/release-manifest.json + artifacts: + when: on_success + expire_in: 90 days + paths: + - dist/ + - release-upload/ + +publish:oss: + stage: publish + image: + name: asia.gcr.io/gopay-systems/cx-oss:latest + entrypoint: [""] + tags: + - gopaysh + needs: + - job: package:release + artifacts: true + id_tokens: + ALICLOUD_ID_TOKEN: + aud: https://source.golabs.io + variables: + ALICLOUD_BUCKET: $ALICLOUD_PRD_BUCKET + ALICLOUD_ACCOUNT_ID: $ALICLOUD_PRD_ACCOUNT_ID + ALICLOUD_ROLE_NAME: $ALICLOUD_PRD_ROLE_NAME + ALIBABA_CLOUD_REGION: $ALIBABA_PRD_CLOUD_REGION + environment: + name: public-release + script: + - | + set -eu + : "${CI_COMMIT_TAG:?release tag is required}" + : "${ALICLOUD_BUCKET:?ALICLOUD_PRD_BUCKET is required}" + : "${ALICLOUD_ACCOUNT_ID:?ALICLOUD_PRD_ACCOUNT_ID is required}" + : "${ALICLOUD_ROLE_NAME:?ALICLOUD_PRD_ROLE_NAME is required}" + : "${ALIBABA_CLOUD_REGION:?ALIBABA_PRD_CLOUD_REGION is required}" + : "${OSS_PUBLIC_BASE_URL:?OSS_PUBLIC_BASE_URL is required}" + test -f release-upload/release-manifest.json + + manifest_tmp=$(mktemp) + mv release-upload/release-manifest.json "$manifest_tmp" + trap 'rm -f .oss-credentials "$manifest_tmp"' EXIT + + oss auth + . .oss-credentials + + destination="oss://$ALICLOUD_BUCKET/$BUCKET_PUBLIC_DIR/releases/$CI_COMMIT_TAG/$CI_PIPELINE_ID/" + ossutil cp release-upload/ "$destination" \ + --recursive --region "$ALIBABA_CLOUD_REGION" + ossutil cp "$manifest_tmp" "${destination}release-manifest.json" \ + --region "$ALIBABA_CLOUD_REGION" + rules: + - if: '$CI_COMMIT_TAG =~ /^v[0-9]+[.][0-9]+[.][0-9]+$/' + when: manual + - when: never + +verify:published: + stage: publish + image: alpine:3.22 + needs: + - publish:oss + before_script: + - apk add --no-cache curl jq + script: + - | + set -eu + : "${CI_COMMIT_TAG:?release tag is required}" + : "${OSS_PUBLIC_BASE_URL:?OSS_PUBLIC_BASE_URL is required}" + + release_base="${OSS_PUBLIC_BASE_URL%/}/$BUCKET_PUBLIC_DIR/releases/$CI_COMMIT_TAG/$CI_PIPELINE_ID" + mkdir public-download + curl --proto '=https' --tlsv1.2 -fsSLo public-download/release-manifest.json \ + "$release_base/release-manifest.json" + jq -e \ + --arg tag "$CI_COMMIT_TAG" \ + --arg commit "$CI_COMMIT_SHA" \ + --arg pipeline "$CI_PIPELINE_ID" \ + '.git_tag == $tag and + .git_commit == $commit and + .gitlab.pipeline_id == $pipeline and + .signed == false and + (.artifacts | length > 0)' \ + public-download/release-manifest.json >/dev/null + + jq -r '.artifacts[] | [.name, .sha256, .url] | @tsv' \ + public-download/release-manifest.json >public-download/artifacts.tsv + tab=$(printf '\t') + while IFS="$tab" read -r name expected url; do + test "$(basename "$name")" = "$name" + case "$url" in + "$release_base/"*) ;; + *) + echo "artifact URL is outside the release path: $url" >&2 + exit 1 + ;; + esac + curl --proto '=https' --tlsv1.2 -fsSLo "public-download/$name" "$url" + actual=$(sha256sum "public-download/$name" | awk '{print $1}') + test "$actual" = "$expected" + done /dev/null +done + +if rg -n 'GITHUB_TOKEN|cosign|deploy_vite|cdn invalidate' "$pipeline"; then + echo "forbidden release dependency found in GitLab pipeline" >&2 + exit 1 +fi From f609429f82a3ea7c1f7da386d3cdfe8f749451e7 Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Tue, 11 Aug 2026 06:27:37 +0700 Subject: [PATCH 5/7] docs: move v0.1 releases to GitLab and OSS --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 44 ----------------------- .goreleaser.yaml | 10 ------ README.md | 37 ++++++++++--------- RELEASE_NOTES_v0.1.0.md | 52 +++++++++++++++------------ tools/test-gitlab-release-pipeline.sh | 13 +++++++ 6 files changed, 61 insertions(+), 97 deletions(-) delete mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0028f5f..fed6660 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -77,7 +77,7 @@ jobs: - uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3 with: version: v2.17.0 - args: release --snapshot --clean --skip=sign + args: release --snapshot --clean - run: ./tools/check_release_artifacts.sh security: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 94ff011..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Release - -on: - push: - tags: ["v*"] - -permissions: - contents: write - id-token: write - attestations: write - -jobs: - release: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - fetch-depth: 0 - persist-credentials: false - - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 - with: - go-version: "1.26.5" - cache: true - - run: ./tools/check_release.sh - - uses: anchore/sbom-action/download-syft@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 - with: - syft-version: v1.42.3 - - uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3 - with: - version: v2.17.0 - args: release --snapshot --clean --skip=sign - - run: ./tools/check_release_artifacts.sh - - uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 - with: - cosign-release: v3.0.6 - - uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3 - with: - version: v2.17.0 - args: release --clean - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 - with: - subject-path: dist/checksums.txt diff --git a/.goreleaser.yaml b/.goreleaser.yaml index dff5566..8706b8c 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -45,15 +45,5 @@ sboms: - id: source artifacts: source -signs: - - cmd: cosign - signature: "${artifact}.sigstore.json" - args: - - sign-blob - - "--bundle=${signature}" - - "${artifact}" - - "--yes" - artifacts: checksum - changelog: use: git diff --git a/README.md b/README.md index f80dc7b..355fcc7 100644 --- a/README.md +++ b/README.md @@ -48,32 +48,31 @@ midtrans agent check --product snap --json --non-interactive ## Release artifact verification -After a signed release is published: +The v0.1.0 release artifacts are checksummed but unsigned. The official GitHub +Release is the discovery and release-notes page; it links to the immutable +Alicloud OSS directory populated by the Goto GitLab release pipeline. -1. Download the archive for your operating system and architecture, - `checksums.txt`, and `checksums.txt.sigstore.json` from the same GitHub - release. -2. Verify the keyless signing bundle before trusting the checksum file: +1. Follow only the Alicloud OSS links published in the official GitHub Release. +2. Download the archive for your operating system and architecture together + with `checksums.txt` from the same versioned OSS directory. +3. Verify the archive against the checksum manifest: ```sh - cosign verify-blob \ - --bundle checksums.txt.sigstore.json \ - --certificate-identity-regexp \ - '^https://github.com/veritrans/midtrans-cli/.github/workflows/release.yml@refs/tags/v' \ - --certificate-oidc-issuer https://token.actions.githubusercontent.com \ - checksums.txt + grep ' $' checksums.txt | sha256sum --check + # macOS alternative: + grep ' $' checksums.txt | shasum -a 256 -c - ``` -3. Verify the archive against `checksums.txt` with `sha256sum -c` or - `shasum -a 256 -c`, extract it, and place `midtrans` on your `PATH`. -4. Run `midtrans agent capabilities --json --non-interactive` and confirm the +4. Extract the archive and place `midtrans` on your `PATH`. +5. Run `midtrans agent capabilities --json --non-interactive` and confirm the expected schema, capabilities, and journeys before an agent uses it. -The future hosted `install.sh` remains unpublished until signed release -artifacts are available and Midtrans approves the official hosting domain. Do -not use an unverified `curl | sh` installer. The v0.1 distribution is limited -to signed, checksummed archives attached directly to the official GitHub -release. Homebrew and npm launchers are deferred until Midtrans separately +Checksums detect corruption or accidental modification after publication, but +they do not authenticate the publisher as a signature would. Artifact signing +is deferred beyond v0.1.0. The hosted `install.sh` remains unpublished; do not +use an unverified `curl | sh` installer. Distribution is limited to the +checksummed but unsigned archives in OSS linked from the official GitHub +Release. Homebrew and npm launchers are deferred until Midtrans separately approves, provisions, and evaluates those channels. ## Sandbox workflow diff --git a/RELEASE_NOTES_v0.1.0.md b/RELEASE_NOTES_v0.1.0.md index ed9ae3c..4245bd2 100644 --- a/RELEASE_NOTES_v0.1.0.md +++ b/RELEASE_NOTES_v0.1.0.md @@ -79,14 +79,15 @@ webhook-idempotency contracts. - A claim of successful live Sandbox execution before the controlled campaign is complete. -The only proposed v0.1.0 distribution channel is the official GitHub release, -using signed, checksummed archives for the five supported OS and architecture -targets. Homebrew and npm distribution require a later, separately reviewed -release. +The official GitHub Release is the v0.1.0 metadata and discovery page. Release +artifacts for the five supported OS and architecture targets are built by Goto +GitLab and stored in Alicloud OSS; the GitHub Release links to that immutable +versioned directory. The v0.1.0 artifacts are checksummed but unsigned. +Homebrew and npm distribution require a later, separately reviewed release. ## Verification evidence -Evidence refreshed on 2026-08-10 against the review candidate working tree. +Evidence refreshed on 2026-08-11 against the review candidate working tree. The exact release commit remains to be recorded after review and merge: | Check | Result | @@ -97,7 +98,7 @@ The exact release commit remains to be recorded after review and merge: | Agent Skill compatibility | Passed against local integration commit `f293153665a9d97b2cb1ab45179b879359370dc2`; that commit is not yet on the public Agent Skill default branch | | Evaluation fixture baselines | Node, Python, and Ruby fixture tests passed; synthetic multi-product command contracts passed without claiming live provider proof | | Controlled 18-run cross-agent evaluation | **Not run; release blocker** | -| GitHub Actions on `main` | **Startup failure before any job; release blocker** ([run 30782737730](https://github.com/veritrans/midtrans-cli/actions/runs/30782737730)) | +| Goto GitLab release pipeline | Configuration implemented and linted, but the `main` snapshot and tag pipelines have not run; **release blocker** | The snapshot binaries are unsigned local review artifacts. They are not release assets and must not be redistributed as the official `v0.1.0`. @@ -105,18 +106,20 @@ release assets and must not be redistributed as the official `v0.1.0`. ## Blocking release gates Every item below must be resolved before anyone creates the `v0.1.0` tag or -invokes the tag-triggered release workflow. +invokes the tag-triggered GitLab release pipeline. - [ ] Run the controlled 18-run campaign described in [`evaluations/README.md`](evaluations/README.md) with the pinned CLI and Agent Skill commits. - [ ] Record at least 17 passing runs out of 18 and zero hard failures. -- [ ] Resolve the GitHub Actions `startup_failure` and obtain a green required - CI run on the reviewed release commit. +- [ ] Manually push the reviewed commit to Goto GitLab `main` and obtain a + green snapshot pipeline. +- [ ] Run the annotated-tag pipeline, publish to Alicloud OSS, and obtain a + green public-download verification job. - [ ] Confirm the compatible Midtrans Agent Skill commit and its release coordination. -- [ ] Review the security boundary, secret redaction, signing, SBOM, and build - provenance plan. +- [ ] Review the security boundary, secret redaction, SBOM, build provenance, + and the explicit unsigned-v0.1.0 exception. - [ ] Approve merchant-facing scope and every external claim. - [ ] Record the final go/no-go decision and the exact commit approved for the tag. @@ -127,25 +130,28 @@ invokes the tag-triggered release workflow. | --- | --- | --- | | Merchant Experience / Product | Scope solves the intended merchant workflow and Sandbox-only boundary is acceptable | Pending | | Payment product owners | Pack journeys and provider-outcome semantics are accurate | Pending | -| Security / Compliance | Credential boundary, redaction, signing, SBOM, and provenance are acceptable | Pending | +| Security / Compliance | Credential boundary, redaction, SBOM, provenance, and the unsigned-v0.1.0 exception are acceptable | Pending | | Developer Experience / Support | Commands, diagnostics, evidence, and support expectations are ready | Pending | -| Release Engineering / Repository Admin | CI and chosen distribution paths are operational | Pending | +| Release Engineering / Repository Admin | Goto GitLab and Alicloud OSS release paths are operational | Pending | | Agent Skill owner | CLI compatibility handshake and pinned Skill release are coordinated | Pending | | Communications / PR | External wording contains only reviewed, demonstrable claims | Pending | ## Go-live sequence after approval 1. Merge any changes requested by reviewers. -2. Re-run the complete release check and cross-platform snapshot on the exact - candidate commit. -3. Complete the controlled evaluation and attach its results. -4. Obtain a green GitHub Actions run and close every blocker above. -5. Record stakeholder approval for the exact commit. -6. Create and push the annotated `v0.1.0` tag. -7. Monitor the release workflow through completion. -8. Verify the published archives, checksums, Sigstore bundle, SBOMs, and build - provenance from a clean environment. -9. Smoke-test the documented installation and agent handshake. +2. Re-run the complete release check and attach the controlled evaluation + results to the exact candidate commit. +3. Approve the exact commit and close every non-CI release gate above. +4. Manually push the exact commit to Goto GitLab `main` and require a green + snapshot pipeline. +5. Create and push the annotated `v0.1.0` tag to Goto GitLab. +6. Run the manual OSS publication job and require green public-download + verification. +7. Push the identical annotated tag to GitHub. +8. Create the GitHub Release with links to the immutable Alicloud OSS release + directory and artifacts. +9. Smoke-test the downloaded archive and agent handshake in a clean + environment. 10. Only then approve and publish the press release. ## Suggested internal review questions diff --git a/tools/test-gitlab-release-pipeline.sh b/tools/test-gitlab-release-pipeline.sh index ec36a5b..8da90a2 100755 --- a/tools/test-gitlab-release-pipeline.sh +++ b/tools/test-gitlab-release-pipeline.sh @@ -32,3 +32,16 @@ if rg -n 'GITHUB_TOKEN|cosign|deploy_vite|cdn invalidate' "$pipeline"; then echo "forbidden release dependency found in GitLab pipeline" >&2 exit 1 fi + +test ! -e "$repo_root/.github/workflows/release.yml" + +if rg -n \ + 'cosign verify-blob|token[.]actions[.]githubusercontent[.]com|checksums[.]txt[.]sigstore[.]json' \ + "$repo_root/README.md" "$repo_root/.goreleaser.yaml"; then + echo "obsolete GitHub-OIDC signing documentation remains" >&2 + exit 1 +fi + +rg -F 'checksummed but unsigned' "$repo_root/README.md" >/dev/null +rg -F 'Goto GitLab' "$repo_root/RELEASE_NOTES_v0.1.0.md" >/dev/null +rg -F 'Alicloud OSS' "$repo_root/RELEASE_NOTES_v0.1.0.md" >/dev/null From 3c5a9cf78a2b1e1fe335dc68f150916ecfa42bb2 Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Tue, 11 Aug 2026 06:32:30 +0700 Subject: [PATCH 6/7] test: enforce GitLab OSS release path --- test/release/infrastructure_test.go | 61 +++++++++++++++-------------- 1 file changed, 32 insertions(+), 29 deletions(-) diff --git a/test/release/infrastructure_test.go b/test/release/infrastructure_test.go index ac15602..c9da3c4 100644 --- a/test/release/infrastructure_test.go +++ b/test/release/infrastructure_test.go @@ -5,7 +5,6 @@ import ( "os" "path/filepath" "runtime" - "slices" "strings" "testing" @@ -47,7 +46,7 @@ func TestCIWorkflowPinsActionsAndRunsRequiredGates(t *testing.T) { "anchore/sbom-action/download-syft@e22c389904149dbc22b58101806040fa8d37a610", "syft-version: v1.42.3", "goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94", - "args: release --snapshot --clean --skip=sign", + "args: release --snapshot --clean", "./tools/check_release_artifacts.sh", } { if !strings.Contains(value, required) { @@ -59,38 +58,47 @@ func TestCIWorkflowPinsActionsAndRunsRequiredGates(t *testing.T) { } } -func TestReleaseWorkflowPinsSupplyChainActionsAndPermissions(t *testing.T) { - value := string(readFile(t, ".github/workflows/release.yml")) +func TestGitLabReleasePipelinePublishesOnlyThroughOSS(t *testing.T) { + githubRelease := filepath.Join(repositoryRoot(t), ".github", "workflows", "release.yml") + if _, err := os.Stat(githubRelease); err == nil { + t.Fatal("GitHub tag release workflow must not exist") + } else if !os.IsNotExist(err) { + t.Fatalf("check GitHub tag release workflow: %v", err) + } + + value := string(readFile(t, ".gitlab-ci.yml")) for _, required := range []string{ - "contents: write", - "id-token: write", - "attestations: write", - "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", - "actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16", - "sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6", - "anchore/sbom-action/download-syft@e22c389904149dbc22b58101806040fa8d37a610", - "goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94", - "actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373", + "validate:release-ref:", + "verify:release:", + "package:release:", + "publish:oss:", + "verify:published:", + "BUCKET_PUBLIC_DIR: al-mp-id-p-midtrans-cli", + "asia.gcr.io/gopay-systems/cx-oss:latest", + "aud: https://source.golabs.io", + "oss auth", + "ossutil cp", + "when: manual", + "release-manifest.json", "./tools/check_release.sh", - "cosign-release: v3.0.6", - "syft-version: v1.42.3", - "args: release --snapshot --clean --skip=sign", "./tools/check_release_artifacts.sh", } { if !strings.Contains(value, required) { - t.Errorf("release workflow missing %q", required) + t.Errorf("GitLab release pipeline missing %q", required) } } var document any if err := yaml.Unmarshal([]byte(value), &document); err != nil { - t.Fatalf("release workflow is invalid YAML: %v", err) + t.Fatalf("GitLab release pipeline is invalid YAML: %v", err) } - if strings.Contains(value, "HOMEBREW_TAP_GITHUB_TOKEN") { - t.Fatal("v0.1 release workflow must not require a Homebrew tap token") + for _, forbidden := range []string{"GITHUB_TOKEN", "cosign", "deploy_vite", "cdn invalidate"} { + if strings.Contains(value, forbidden) { + t.Errorf("GitLab release pipeline contains forbidden dependency %q", forbidden) + } } } -func TestGoReleaserPublishesFiveSignedGitHubTargetsWithoutExternalTap(t *testing.T) { +func TestGoReleaserBuildsFiveUnsignedTargetsWithoutExternalTap(t *testing.T) { value := readFile(t, ".goreleaser.yaml") var document struct { Version int `yaml:"version"` @@ -117,11 +125,7 @@ func TestGoReleaserPublishesFiveSignedGitHubTargetsWithoutExternalTap(t *testing SBOMs []struct { Artifacts string `yaml:"artifacts"` } `yaml:"sboms"` - Signs []struct { - Command string `yaml:"cmd"` - Args []string `yaml:"args"` - Artifacts string `yaml:"artifacts"` - } `yaml:"signs"` + Signs []any `yaml:"signs"` HomebrewCasks []any `yaml:"homebrew_casks"` } if err := yaml.Unmarshal(value, &document); err != nil { @@ -165,9 +169,8 @@ func TestGoReleaserPublishesFiveSignedGitHubTargetsWithoutExternalTap(t *testing if !sbomArtifacts["archive"] || !sbomArtifacts["source"] { t.Fatalf("SBOM artifacts = %#v, want archive and source", sbomArtifacts) } - if len(document.Signs) != 1 || document.Signs[0].Command != "cosign" || - document.Signs[0].Artifacts != "checksum" || !slices.Contains(document.Signs[0].Args, "sign-blob") { - t.Fatalf("checksum signing is not configured with cosign sign-blob: %#v", document.Signs) + if len(document.Signs) != 0 { + t.Fatalf("v0.1 artifacts must be unsigned, found signing config: %#v", document.Signs) } if len(document.HomebrewCasks) != 0 { t.Fatal("v0.1 release must not require an external Homebrew tap or token") From 879ccddb99bab3418819d76e5ed1cb314c76133b Mon Sep 17 00:00:00 2001 From: "m.salis" Date: Tue, 11 Aug 2026 06:35:31 +0700 Subject: [PATCH 7/7] style: normalize release files --- .gitlab-ci.yml | 1 - docs/gitlab-oss-release-design.md | 1 - 2 files changed, 2 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index c2ac34e..35f56e9 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -203,4 +203,3 @@ verify:published: - if: '$CI_COMMIT_TAG =~ /^v[0-9]+[.][0-9]+[.][0-9]+$/' when: on_success - when: never - diff --git a/docs/gitlab-oss-release-design.md b/docs/gitlab-oss-release-design.md index 4682755..bf72a0e 100644 --- a/docs/gitlab-oss-release-design.md +++ b/docs/gitlab-oss-release-design.md @@ -211,4 +211,3 @@ Implementation is complete when all of the following are true: - The GitHub repository contains no claim that GitHub Actions, signatures, the controlled evaluation, stakeholder approvals, or the public release have completed when they have not. -