diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 472a857..074e268 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -190,6 +190,7 @@ jobs: s3_directory: ${{ steps.s3-directory.outputs.S3_DIRECTORY }} binaries_manifest: ${{ steps.generate-binaries-manifest.outputs.binaries_manifest }} binaries_checksums: ${{ steps.output-checksums.outputs.checksums }} + released_at: ${{ steps.release-meta.outputs.released_at }} steps: - name: Checkout caller repo uses: actions/checkout@v5 @@ -402,6 +403,30 @@ jobs: go-version-file: "_workflows/go.mod" cache: false + - name: Fetch release metadata + id: release-meta + working-directory: _workflows + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + mkdir -p "$GITHUB_WORKSPACE/_release_metadata" + RELEASE_JSON="$GITHUB_WORKSPACE/_release_metadata/github-release.json" + CHANGELOG_FILE="$GITHUB_WORKSPACE/_release_metadata/binaries-changelog.md" + WORKFLOW_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + + if ! gh api "repos/${{ github.repository }}/releases/tags/${{ inputs.tag }}" > "$RELEASE_JSON" 2>/dev/null; then + echo '{}' > "$RELEASE_JSON" + fi + + go run ./cmd/release-metadata \ + -repo-dir ../_caller \ + -tag "${{ inputs.tag }}" \ + -workflow-time "$WORKFLOW_TIME" \ + -github-release-json "$RELEASE_JSON" \ + -changelog-file "$CHANGELOG_FILE" \ + >> "$GITHUB_OUTPUT" + - name: Generate manifest.json id: generate-binaries-manifest working-directory: _workflows @@ -413,6 +438,7 @@ jobs: -repo-name "${{ github.event.repository.name }}" \ -org-name "${{ github.event.repository.owner.login }}" \ -tag "${{ inputs.tag }}" \ + -released-at "${{ steps.release-meta.outputs.released_at }}" \ -base-url "${{ env.CDN_BASE_URL }}/${{ steps.s3-directory.outputs.S3_DIRECTORY }}") # Debug output @@ -1293,8 +1319,8 @@ jobs: # ================================================================ record-registry-api: # Use !cancelled() so the explicit needs.result check controls skipped-job behavior. - if: ${{ !cancelled() && needs.publish-release-manifest.result == 'success' && needs.verify-release.result == 'success' }} - needs: [determine-workflows-ref, publish-release-manifest, verify-release] + if: ${{ !cancelled() && needs.goreleaser-binaries.result == 'success' && needs.publish-release-manifest.result == 'success' && needs.verify-release.result == 'success' }} + needs: [determine-workflows-ref, goreleaser-binaries, publish-release-manifest, verify-release] permissions: id-token: write contents: read @@ -1362,17 +1388,33 @@ jobs: - name: Fetch release metadata id: release-meta - if: steps.registry-oidc.outcome == 'success' - continue-on-error: true + working-directory: _workflows env: GH_TOKEN: ${{ github.token }} run: | - RELEASE_JSON=$(gh api "repos/${{ github.repository }}/releases/tags/${{ inputs.tag }}" 2>/dev/null || echo '{}') - echo "$RELEASE_JSON" | jq -r '.body // empty' > /tmp/changelog.md || true - # Prefer publish time; created_at can predate visibility for draft releases - # and for normal create-then-publish workflow runs. - RELEASED_AT=$(echo "$RELEASE_JSON" | jq -r '.published_at // .created_at // empty') - echo "released_at=$RELEASED_AT" >> "$GITHUB_OUTPUT" + set -euo pipefail + mkdir -p "$GITHUB_WORKSPACE/_release_metadata" + RELEASE_JSON="$GITHUB_WORKSPACE/_release_metadata/github-release.json" + CHANGELOG_FILE="$GITHUB_WORKSPACE/_release_metadata/changelog.md" + WORKFLOW_TIME="${{ needs.goreleaser-binaries.outputs.released_at }}" + if [ -z "$WORKFLOW_TIME" ]; then + WORKFLOW_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + fi + + if ! gh api "repos/${{ github.repository }}/releases/tags/${{ inputs.tag }}" > "$RELEASE_JSON" 2>/dev/null; then + echo '{}' > "$RELEASE_JSON" + fi + + go run ./cmd/release-metadata \ + -repo-dir ../_connector \ + -tag "${{ inputs.tag }}" \ + -workflow-time "$WORKFLOW_TIME" \ + -github-release-json "$RELEASE_JSON" \ + -changelog-file "$CHANGELOG_FILE" \ + >> "$GITHUB_OUTPUT" + # Keep the registry timestamp aligned with the signed manifest. + echo "released_at=$WORKFLOW_TIME" >> "$GITHUB_OUTPUT" + echo "released_at_source=binaries-manifest" >> "$GITHUB_OUTPUT" - name: Write merged manifest from manifest publication job working-directory: _workflows @@ -1387,6 +1429,7 @@ jobs: working-directory: _workflows env: REGISTRY_API_TOKEN: ${{ steps.registry-oidc.outputs.token }} + RELEASED_AT: ${{ needs.goreleaser-binaries.outputs.released_at }} run: | DOCS_FLAG="" if [ "${{ steps.read-docs.outputs.has_docs }}" = "true" ]; then @@ -1394,8 +1437,8 @@ jobs: fi CHANGELOG_FLAG="" - if [ -s /tmp/changelog.md ]; then - CHANGELOG_FLAG="-changelog /tmp/changelog.md" + if [ -s "${{ steps.release-meta.outputs.changelog_file }}" ]; then + CHANGELOG_FLAG="-changelog ${{ steps.release-meta.outputs.changelog_file }}" fi CONFIG_SCHEMA_FLAG="" @@ -1409,8 +1452,8 @@ jobs: fi RELEASED_AT_FLAG="" - if [ -n "${{ steps.release-meta.outputs.released_at }}" ]; then - RELEASED_AT_FLAG="-released-at ${{ steps.release-meta.outputs.released_at }}" + if [ -n "$RELEASED_AT" ]; then + RELEASED_AT_FLAG="-released-at $RELEASED_AT" fi go run ./cmd/record-release \ diff --git a/cmd/generate-manifest/main.go b/cmd/generate-manifest/main.go index 7ece3f7..1718fbd 100644 --- a/cmd/generate-manifest/main.go +++ b/cmd/generate-manifest/main.go @@ -20,17 +20,19 @@ import ( func main() { var ( - assetDir string - repoName string - orgName string - tag string - baseURL string + assetDir string + repoName string + orgName string + tag string + baseURL string + releasedAt string ) flag.StringVar(&assetDir, "asset-dir", ".", "Directory containing distribution artifacts") flag.StringVar(&repoName, "repo-name", "", "Repository name") flag.StringVar(&orgName, "org-name", "", "Organization name") flag.StringVar(&tag, "tag", "", "Release tag (e.g., v0.0.8)") flag.StringVar(&baseURL, "base-url", "", "Base URL for artifact downloads") + flag.StringVar(&releasedAt, "released-at", "", "Release timestamp in RFC3339 format") flag.Parse() if repoName == "" || orgName == "" || tag == "" || baseURL == "" { @@ -38,7 +40,16 @@ func main() { os.Exit(1) } - now := time.Now().UTC() + releaseTime := time.Now().UTC() + if strings.TrimSpace(releasedAt) != "" { + var err error + releaseTime, err = time.Parse(time.RFC3339, strings.TrimSpace(releasedAt)) + if err != nil { + fmt.Fprintf(os.Stderr, "generate-manifest: error: released-at must be RFC3339: %v\n", err) + os.Exit(1) + } + releaseTime = releaseTime.UTC() + } assets := make(map[string]*pb.Asset) // Asset patterns: platform -> (pattern, mediaType) @@ -154,7 +165,7 @@ func main() { Name: &repoName, Org: &orgName, Semver: &tag, - ReleasedAt: timestamppb.New(now), + ReleasedAt: timestamppb.New(releaseTime), Assets: assets, SignatureHref: &signatureHref, CertificateHref: &certificateHref, diff --git a/cmd/release-metadata/main.go b/cmd/release-metadata/main.go new file mode 100644 index 0000000..c3a8c3e --- /dev/null +++ b/cmd/release-metadata/main.go @@ -0,0 +1,215 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "io" + "os" + "os/exec" + "regexp" + "strings" + "time" +) + +var semverTagPattern = regexp.MustCompile(`^v[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$`) + +type githubRelease struct { + Body string `json:"body"` + PublishedAt string `json:"published_at"` + CreatedAt string `json:"created_at"` +} + +type releaseMetadata struct { + ReleasedAt string + ReleasedAtSource string + Changelog string + ChangelogSource string +} + +func main() { + var repoDir, tag, workflowTime, githubReleaseJSON, changelogFile string + flag.StringVar(&repoDir, "repo-dir", "", "Path to the checked-out release repository") + flag.StringVar(&tag, "tag", "", "Release tag") + flag.StringVar(&workflowTime, "workflow-time", "", "Workflow timestamp fallback in RFC3339 format") + flag.StringVar(&githubReleaseJSON, "github-release-json", "", "Optional GitHub Release JSON file") + flag.StringVar(&changelogFile, "changelog-file", "", "Path to write the computed changelog") + flag.Parse() + + if repoDir == "" || tag == "" || workflowTime == "" || changelogFile == "" { + fmt.Fprintln(os.Stderr, "release-metadata: error: -repo-dir, -tag, -workflow-time, and -changelog-file are required") + os.Exit(2) + } + + md, err := computeReleaseMetadata(repoDir, tag, workflowTime, githubReleaseJSON) + if err != nil { + fmt.Fprintf(os.Stderr, "release-metadata: error: %v\n", err) + os.Exit(1) + } + if err := os.WriteFile(changelogFile, []byte(md.Changelog), 0o600); err != nil { + fmt.Fprintf(os.Stderr, "release-metadata: error: write changelog: %v\n", err) + os.Exit(1) + } + writeOutput(os.Stdout, md, changelogFile) +} + +func computeReleaseMetadata(repoDir, tag, workflowTime, githubReleaseJSON string) (releaseMetadata, error) { + workflowReleasedAt, err := normalizeRFC3339(workflowTime) + if err != nil { + return releaseMetadata{}, fmt.Errorf("workflow time: %w", err) + } + + var md releaseMetadata + if githubReleaseJSON != "" { + if release, ok := readGitHubRelease(githubReleaseJSON); ok { + if strings.TrimSpace(release.Body) != "" { + md.Changelog = release.Body + md.ChangelogSource = "github-release-body" + } + if releasedAt, source := releaseTimestamp(release); releasedAt != "" { + md.ReleasedAt = releasedAt + md.ReleasedAtSource = source + } + } + } + + tagMessage, taggerTime := annotatedTagMetadata(repoDir, tag) + if md.Changelog == "" && strings.TrimSpace(tagMessage) != "" { + md.Changelog = tagMessage + md.ChangelogSource = "annotated-tag-message" + } + if md.ReleasedAt == "" && taggerTime != "" { + md.ReleasedAt = taggerTime + md.ReleasedAtSource = "annotated-tagger-time" + } + + if md.Changelog == "" { + changelog, source := generatedChangelog(repoDir, tag) + if strings.TrimSpace(changelog) != "" { + md.Changelog = changelog + md.ChangelogSource = source + } + } + + if md.ReleasedAt == "" { + md.ReleasedAt = workflowReleasedAt + md.ReleasedAtSource = "workflow-time" + } + if md.ChangelogSource == "" { + md.ChangelogSource = "empty" + } + return md, nil +} + +func readGitHubRelease(path string) (githubRelease, bool) { + data, err := os.ReadFile(path) + if err != nil || len(strings.TrimSpace(string(data))) == 0 { + return githubRelease{}, false + } + var release githubRelease + if err := json.Unmarshal(data, &release); err != nil { + return githubRelease{}, false + } + if release.Body == "" && release.PublishedAt == "" && release.CreatedAt == "" { + return githubRelease{}, false + } + return release, true +} + +func releaseTimestamp(release githubRelease) (string, string) { + if ts, err := normalizeRFC3339(release.PublishedAt); err == nil && ts != "" { + return ts, "github-release-published-at" + } + if ts, err := normalizeRFC3339(release.CreatedAt); err == nil && ts != "" { + return ts, "github-release-created-at" + } + return "", "" +} + +func annotatedTagMetadata(repoDir, tag string) (string, string) { + ref := "refs/tags/" + tag + tagType, err := gitOutput(repoDir, "cat-file", "-t", ref) + if err != nil || strings.TrimSpace(tagType) != "tag" { + return "", "" + } + out, err := gitOutput(repoDir, "for-each-ref", "--format=%(taggerdate:iso-strict)%00%(contents)", ref) + if err != nil { + return "", "" + } + parts := strings.SplitN(out, "\x00", 2) + if len(parts) != 2 { + return strings.TrimSpace(out), "" + } + taggerTime := "" + if ts, err := normalizeRFC3339(strings.TrimSpace(parts[0])); err == nil { + taggerTime = ts + } + return strings.TrimSpace(parts[1]), taggerTime +} + +func generatedChangelog(repoDir, tag string) (string, string) { + currentCommit, err := gitOutput(repoDir, "rev-list", "-n", "1", "refs/tags/"+tag) + if err != nil { + return "", "empty" + } + tagsOut, err := gitOutput(repoDir, "tag", "--merged", strings.TrimSpace(currentCommit), "--sort=-v:refname") + if err != nil { + return "", "empty" + } + + foundPrevious := false + for _, candidate := range strings.Fields(tagsOut) { + if candidate == tag || !semverTagPattern.MatchString(candidate) { + continue + } + foundPrevious = true + changelog := commitList(repoDir, candidate+".."+tag) + if strings.TrimSpace(changelog) != "" { + return changelog, "commit-list:" + candidate + ".." + tag + } + } + if !foundPrevious { + changelog := commitList(repoDir, tag) + if strings.TrimSpace(changelog) != "" { + return changelog, "commit-list:" + tag + } + } + return "", "empty" +} + +func commitList(repoDir, rev string) string { + out, err := gitOutput(repoDir, "log", "--no-merges", "--format=- %s (%h)", rev) + if err != nil { + return "" + } + return strings.TrimSpace(out) + "\n" +} + +func normalizeRFC3339(value string) (string, error) { + value = strings.TrimSpace(value) + if value == "" { + return "", nil + } + t, err := time.Parse(time.RFC3339, value) + if err != nil { + return "", err + } + return t.UTC().Format(time.RFC3339), nil +} + +func gitOutput(repoDir string, args ...string) (string, error) { + cmdArgs := append([]string{"-C", repoDir}, args...) + cmd := exec.Command("git", cmdArgs...) + out, err := cmd.Output() + if err != nil { + return "", err + } + return string(out), nil +} + +func writeOutput(out io.Writer, md releaseMetadata, changelogFile string) { + fmt.Fprintf(out, "released_at=%s\n", md.ReleasedAt) + fmt.Fprintf(out, "released_at_source=%s\n", md.ReleasedAtSource) + fmt.Fprintf(out, "changelog_file=%s\n", changelogFile) + fmt.Fprintf(out, "changelog_source=%s\n", md.ChangelogSource) +} diff --git a/cmd/release-metadata/main_test.go b/cmd/release-metadata/main_test.go new file mode 100644 index 0000000..3e08b20 --- /dev/null +++ b/cmd/release-metadata/main_test.go @@ -0,0 +1,100 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestGitHubReleaseMetadataWins(t *testing.T) { + repo := initRepo(t) + commitFile(t, repo, "connector.go", "package main\n", "initial") + runGit(t, repo, "tag", "-a", "v1.0.0", "-m", "annotated notes") + releaseJSON := filepath.Join(t.TempDir(), "release.json") + if err := os.WriteFile(releaseJSON, []byte(`{"body":"GitHub release notes\n","published_at":"2026-06-05T14:54:03Z","created_at":"2026-06-05T14:00:00Z"}`), 0o600); err != nil { + t.Fatal(err) + } + + md, err := computeReleaseMetadata(repo, "v1.0.0", "2026-06-05T15:00:00Z", releaseJSON) + if err != nil { + t.Fatal(err) + } + if md.Changelog != "GitHub release notes\n" || md.ChangelogSource != "github-release-body" { + t.Fatalf("changelog = %q from %s", md.Changelog, md.ChangelogSource) + } + if md.ReleasedAt != "2026-06-05T14:54:03Z" || md.ReleasedAtSource != "github-release-published-at" { + t.Fatalf("releasedAt = %q from %s", md.ReleasedAt, md.ReleasedAtSource) + } +} + +func TestAnnotatedTagFallback(t *testing.T) { + repo := initRepo(t) + commitFile(t, repo, "connector.go", "package main\n", "initial") + runGitWithEnv(t, repo, []string{"GIT_COMMITTER_DATE=2026-06-04T12:34:56Z"}, "tag", "-a", "v1.0.0", "-m", "annotated notes") + + md, err := computeReleaseMetadata(repo, "v1.0.0", "2026-06-05T15:00:00Z", "") + if err != nil { + t.Fatal(err) + } + if md.Changelog != "annotated notes" || md.ChangelogSource != "annotated-tag-message" { + t.Fatalf("changelog = %q from %s", md.Changelog, md.ChangelogSource) + } + if md.ReleasedAt != "2026-06-04T12:34:56Z" || md.ReleasedAtSource != "annotated-tagger-time" { + t.Fatalf("releasedAt = %q from %s", md.ReleasedAt, md.ReleasedAtSource) + } +} + +func TestGeneratedCommitListFallback(t *testing.T) { + repo := initRepo(t) + commitFile(t, repo, "connector.go", "package main\n", "initial") + runGit(t, repo, "tag", "v1.0.0") + commitFile(t, repo, "connector.go", "package main\n// second\n", "second change") + runGit(t, repo, "tag", "v1.1.0") + + md, err := computeReleaseMetadata(repo, "v1.1.0", "2026-06-05T15:00:00Z", "") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(md.Changelog, "- second change") || md.ChangelogSource != "commit-list:v1.0.0..v1.1.0" { + t.Fatalf("changelog = %q from %s", md.Changelog, md.ChangelogSource) + } + if md.ReleasedAt != "2026-06-05T15:00:00Z" || md.ReleasedAtSource != "workflow-time" { + t.Fatalf("releasedAt = %q from %s", md.ReleasedAt, md.ReleasedAtSource) + } +} + +func initRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + runGit(t, dir, "init", "-b", "main") + runGit(t, dir, "config", "user.email", "test@example.com") + runGit(t, dir, "config", "user.name", "Test User") + return dir +} + +func commitFile(t *testing.T, repo, name, contents, message string) { + t.Helper() + if err := os.WriteFile(filepath.Join(repo, name), []byte(contents), 0o600); err != nil { + t.Fatal(err) + } + runGit(t, repo, "add", name) + runGit(t, repo, "commit", "-m", message) +} + +func runGit(t *testing.T, repo string, args ...string) { + t.Helper() + runGitWithEnv(t, repo, nil, args...) +} + +func runGitWithEnv(t *testing.T, repo string, env []string, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = repo + cmd.Env = append(os.Environ(), env...) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out) + } +}