From 7514221d4e448bb10a01dcda21405e3b0ae2505a Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Thu, 8 Oct 2026 17:21:11 -0400 Subject: [PATCH 1/4] fix(auth): backport JWT issuer and audience isolation to v0.74 --- DOCUMENTATION.md | 12 ++ module/jwt_auth.go | 75 ++++++--- module/jwt_auth_boundary_test.go | 177 +++++++++++++++++++++ plugins/auth/jwt_boundary_test.go | 52 ++++++ plugins/auth/plugin.go | 5 + schema/module_schema.go | 1 + schema/module_schema_test.go | 2 +- schema/testdata/editor-schemas.golden.json | 6 + 8 files changed, 307 insertions(+), 23 deletions(-) create mode 100644 module/jwt_auth_boundary_test.go create mode 100644 plugins/auth/jwt_boundary_test.go diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 1bd85046..c7adc10d 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -63,6 +63,18 @@ All modules are instantiated from YAML config via the plugin factory registry. O > `auth.modular` was removed in favor of `auth.jwt`. +`auth.jwt` accepts an optional `audience` string. A configured value adds the +`aud` claim to access and refresh tokens and requires both that audience and +the configured `issuer` when authenticating, reading profiles, or refreshing +tokens. Configure distinct issuer/audience pairs for environments that must +not accept each other's tokens. Legacy tokens without those claims are rejected +when this option is enabled; users must sign in again. Omitting `audience` +preserves legacy validation. An explicitly configured empty or non-string +audience fails initialization and validation instead of falling back to legacy +behavior. Every environment in a mutual trust boundary must +enforce the policy before relying on it, especially when signing keys are shared. + + ### API & CQRS | Type | Description | Plugin | |------|-------------|--------| diff --git a/module/jwt_auth.go b/module/jwt_auth.go index 5e4680b5..e0a5eb1c 100644 --- a/module/jwt_auth.go +++ b/module/jwt_auth.go @@ -35,6 +35,8 @@ type JWTAuthModule struct { secret string tokenExpiry time.Duration issuer string + audience string + validateAudience bool seedFile string responseFormat string // "standard" (default) or "v1" (access_token/refresh_token) users map[string]*User // keyed by email (used when no external userStore) @@ -66,6 +68,14 @@ func NewJWTAuthModule(name, secret string, tokenExpiry time.Duration, issuer str } } +// SetAudience opts into issuer and audience validation for every JWT entry point. +// Configure this before Init. Explicitly configured audiences must be nonempty. +// Omit this call to preserve legacy validation. +func (j *JWTAuthModule) SetAudience(audience string) { + j.audience = audience + j.validateAudience = true +} + // SetSeedFile sets the path to a JSON file of seed users to load on start. func (j *JWTAuthModule) SetSeedFile(path string) { j.seedFile = path @@ -105,6 +115,9 @@ func (j *JWTAuthModule) Init(app modular.Application) error { if len(j.secret) < 32 { return fmt.Errorf("JWT secret must be at least 32 bytes for security") } + if err := j.validateAudienceConfig(); err != nil { + return err + } j.app = app j.logger = app.Logger() @@ -127,9 +140,24 @@ func (j *JWTAuthModule) Init(app modular.Application) error { return nil } -// Authenticate implements AuthProvider -func (j *JWTAuthModule) Authenticate(tokenStr string) (bool, map[string]any, error) { - token, err := jwt.Parse(tokenStr, func(token *jwt.Token) (any, error) { +func (j *JWTAuthModule) validateAudienceConfig() error { + if j.validateAudience && strings.TrimSpace(j.audience) == "" { + return fmt.Errorf("JWT audience must be a nonempty string when configured") + } + return nil +} + +// parseToken applies the same algorithm and optional environment boundary to +// authentication, profile requests, and refresh requests. +func (j *JWTAuthModule) parseToken(tokenStr string) (*jwt.Token, error) { + if err := j.validateAudienceConfig(); err != nil { + return nil, err + } + var options []jwt.ParserOption + if j.validateAudience { + options = append(options, jwt.WithIssuer(j.issuer), jwt.WithAudience(j.audience)) + } + return jwt.Parse(tokenStr, func(token *jwt.Token) (any, error) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } @@ -137,7 +165,12 @@ func (j *JWTAuthModule) Authenticate(tokenStr string) (bool, map[string]any, err return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } return []byte(j.secret), nil - }) + }, options...) +} + +// Authenticate implements AuthProvider +func (j *JWTAuthModule) Authenticate(tokenStr string) (bool, map[string]any, error) { + token, err := j.parseToken(tokenStr) if err != nil { return false, nil, nil //nolint:nilerr // Invalid token is a failed auth, not an error } @@ -426,15 +459,7 @@ func (j *JWTAuthModule) extractUserFromRequest(r *http.Request) (*User, error) { return nil, fmt.Errorf("bearer token required") } - token, err := jwt.Parse(tokenStr, func(token *jwt.Token) (any, error) { - if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { - return nil, fmt.Errorf("unexpected signing method") - } - if token.Method.Alg() != jwt.SigningMethodHS256.Alg() { - return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) - } - return []byte(j.secret), nil - }) + token, err := j.parseToken(tokenStr) if err != nil { return nil, fmt.Errorf("invalid token") } @@ -458,6 +483,9 @@ func (j *JWTAuthModule) extractUserFromRequest(r *http.Request) (*User, error) { } func (j *JWTAuthModule) generateToken(user *User) (string, error) { + if err := j.validateAudienceConfig(); err != nil { + return "", err + } claims := jwt.MapClaims{ "sub": user.ID, "email": user.Email, @@ -478,6 +506,10 @@ func (j *JWTAuthModule) generateToken(user *User) (string, error) { claims["programIds"] = programIds } + if j.audience != "" { + claims["aud"] = j.audience + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) return token.SignedString([]byte(j.secret)) } @@ -497,6 +529,9 @@ func (j *JWTAuthModule) buildUserResponse(user *User) map[string]any { // generateRefreshToken creates a refresh JWT with longer expiry (7 days) and a "refresh" type claim. func (j *JWTAuthModule) generateRefreshToken(user *User) (string, error) { + if err := j.validateAudienceConfig(); err != nil { + return "", err + } claims := jwt.MapClaims{ "sub": user.ID, "email": user.Email, @@ -505,6 +540,10 @@ func (j *JWTAuthModule) generateRefreshToken(user *User) (string, error) { "iat": time.Now().Unix(), "exp": time.Now().Add(7 * 24 * time.Hour).Unix(), } + if j.audience != "" { + claims["aud"] = j.audience + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) return token.SignedString([]byte(j.secret)) } @@ -526,15 +565,7 @@ func (j *JWTAuthModule) handleRefresh(w http.ResponseWriter, r *http.Request) { return } - token, err := jwt.Parse(req.RefreshToken, func(token *jwt.Token) (any, error) { - if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { - return nil, fmt.Errorf("unexpected signing method") - } - if token.Method.Alg() != jwt.SigningMethodHS256.Alg() { - return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) - } - return []byte(j.secret), nil - }) + token, err := j.parseToken(req.RefreshToken) if err != nil { w.WriteHeader(http.StatusUnauthorized) _ = json.NewEncoder(w).Encode(map[string]string{"error": "invalid refresh token"}) diff --git a/module/jwt_auth_boundary_test.go b/module/jwt_auth_boundary_test.go new file mode 100644 index 00000000..c5310bb8 --- /dev/null +++ b/module/jwt_auth_boundary_test.go @@ -0,0 +1,177 @@ +package module + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +func boundaryToken(t *testing.T, issuer string, audience any) string { + t.Helper() + claims := jwt.MapClaims{ + "sub": "boundary-user", "email": "boundary@example.invalid", "type": "refresh", + "iat": time.Now().Unix(), "exp": time.Now().Add(time.Hour).Unix(), + } + if issuer != "" { + claims["iss"] = issuer + } + if audience != nil { + claims["aud"] = audience + } + token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(strings.Repeat("boundary-fixture-", 3))) + if err != nil { + t.Fatal("signing synthetic token failed") + } + return token +} + +func boundaryModule(issuer, audience string) *JWTAuthModule { + m := NewJWTAuthModule("auth-jwt", strings.Repeat("boundary-fixture-", 3), time.Hour, issuer) + if audience != "" { + m.SetAudience(audience) + } + m.users["boundary@example.invalid"] = &User{ID: "boundary-user", Email: "boundary@example.invalid"} + return m +} + +func TestJWTAuthEnvironmentBoundary(t *testing.T) { + for _, environment := range []string{"staging", "production"} { + t.Run(environment, func(t *testing.T) { + issuer := "buymywishlist:" + environment + audience := "bmw:" + environment + other := "production" + if environment == "production" { + other = "staging" + } + m := boundaryModule(issuer, audience) + for _, tc := range []struct { + name, issuer string + audience any + valid bool + }{ + {"matching", issuer, audience, true}, + {"audience-array-matching", issuer, []string{audience}, true}, + {"other-environment", "buymywishlist:" + other, "bmw:" + other, false}, + {"wrong-issuer", "buymywishlist:" + other, audience, false}, + {"wrong-audience", issuer, "bmw:" + other, false}, + {"missing-issuer", "", audience, false}, + {"missing-audience", issuer, nil, false}, + {"empty-audience-array", issuer, []string{}, false}, + {"malformed-audience", issuer, 123, false}, + {"legacy", "buymywishlist", nil, false}, + } { + t.Run(tc.name, func(t *testing.T) { + token := boundaryToken(t, tc.issuer, tc.audience) + valid, claims, err := m.Authenticate(token) + if err != nil || valid != tc.valid || (!tc.valid && claims != nil) { + t.Fatal("unexpected authentication boundary result") + } + + // Profile parsing and refresh must use the same boundary as Authenticate. + req := httptest.NewRequest(http.MethodGet, "/auth/profile", nil) + req.Header.Set("Authorization", "Bearer "+token) + user, profileErr := m.extractUserFromRequest(req) + if (profileErr == nil && user != nil) != tc.valid { + t.Fatal("profile entry point bypassed the boundary") + } + body, err := json.Marshal(map[string]string{"refresh_token": token}) + if err != nil { + t.Fatal("encoding synthetic request failed") + } + refresh := httptest.NewRecorder() + m.handleRefresh(refresh, httptest.NewRequest(http.MethodPost, "/auth/refresh", bytes.NewReader(body))) + wantStatus := http.StatusUnauthorized + if tc.valid { + wantStatus = http.StatusOK + } + if refresh.Code != wantStatus { + t.Fatalf("refresh status = %d; want %d", refresh.Code, wantStatus) + } + }) + } + }) + } +} + +func TestJWTAuthEnvironmentBoundaryGeneratedTokens(t *testing.T) { + m := boundaryModule("buymywishlist:staging", "bmw:staging") + user := m.users["boundary@example.invalid"] + for _, mint := range []func(*User) (string, error){m.generateToken, m.generateRefreshToken} { + token, err := mint(user) + if err != nil { + t.Fatal("minting synthetic token failed") + } + valid, claims, err := m.Authenticate(token) + if err != nil || !valid || claims["iss"] != "buymywishlist:staging" || claims["aud"] != "bmw:staging" { + t.Fatal("generated token lacks enforced environment claims") + } + } +} + +func TestJWTAuthEnvironmentBoundaryLegacyCompatibility(t *testing.T) { + m := boundaryModule("legacy-configured-issuer", "") + valid, _, err := m.Authenticate(boundaryToken(t, "different-legacy-issuer", nil)) + if err != nil || !valid { + t.Fatal("unconfigured audience changed legacy validation") + } +} + +func TestJWTAuthEnvironmentBoundaryEmptyAudienceFailsClosed(t *testing.T) { + for _, audience := range []string{"", " "} { + m := boundaryModule("buymywishlist:staging", "") + m.SetAudience(audience) + if err := m.Init(NewMockApplication()); err == nil { + t.Fatal("empty configured audience did not fail initialization") + } + valid, _, err := m.Authenticate(boundaryToken(t, "buymywishlist:staging", nil)) + if err != nil || valid { + t.Fatal("empty configured audience accepted a legacy token") + } + for _, mint := range []func(*User) (string, error){m.generateToken, m.generateRefreshToken} { + if token, err := mint(m.users["boundary@example.invalid"]); err == nil || token != "" { + t.Fatal("empty configured audience minted a token") + } + } + } +} + +func TestAuthValidateStepJWTEnvironmentBoundary(t *testing.T) { + m := boundaryModule("buymywishlist:staging", "bmw:staging") + app := newTestAuthApp("auth-jwt", m) + step, err := NewAuthValidateStepFactory()("authorize", map[string]any{ + "auth_module": "auth-jwt", "token_source": "steps.request.headers.Authorization", + }, app) + if err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name, issuer, audience string + stop bool + }{ + {"same-environment", "buymywishlist:staging", "bmw:staging", false}, + {"other-environment", "buymywishlist:production", "bmw:production", true}, + } { + t.Run(tc.name, func(t *testing.T) { + pc := NewPipelineContext(nil, nil) + pc.MergeStepOutput("request", map[string]any{"headers": map[string]any{ + "Authorization": "Bearer " + boundaryToken(t, tc.issuer, tc.audience), + }}) + response := httptest.NewRecorder() + pc.Metadata["_http_response_writer"] = response + result, err := step.Execute(context.Background(), pc) + if err != nil || result.Stop != tc.stop { + t.Fatal("unexpected pipeline authentication result") + } + if tc.stop && (response.Code != http.StatusUnauthorized || result.Output["status"] != http.StatusUnauthorized || pc.Metadata["_response_handled"] != true) { + t.Fatal("cross-environment request did not stop with HTTP 401") + } + }) + } +} diff --git a/plugins/auth/jwt_boundary_test.go b/plugins/auth/jwt_boundary_test.go new file mode 100644 index 00000000..cad53075 --- /dev/null +++ b/plugins/auth/jwt_boundary_test.go @@ -0,0 +1,52 @@ +package auth + +import ( + "strings" + "testing" + "time" + + "github.com/GoCodeAlone/workflow/module" + "github.com/golang-jwt/jwt/v5" +) + +func TestJWTFactoryEnvironmentBoundary(t *testing.T) { + secret := strings.Repeat("boundary-fixture-", 3) + m, ok := New().ModuleFactories()["auth.jwt"]("auth-jwt", map[string]any{ + "secret": secret, "issuer": "buymywishlist:staging", "audience": "bmw:staging", + }).(*module.JWTAuthModule) + if !ok { + t.Fatal("factory returned the wrong module") + } + for _, audience := range []string{"bmw:staging", "bmw:production"} { + claims := jwt.MapClaims{"iss": "buymywishlist:staging", "aud": audience, "exp": time.Now().Add(time.Hour).Unix()} + token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret)) + if err != nil { + t.Fatal("signing synthetic token failed") + } + valid, _, err := m.Authenticate(token) + if err != nil || valid != (audience == "bmw:staging") { + t.Fatal("factory did not enable the configured boundary") + } + } +} + +func TestJWTFactoryInvalidAudienceFailsClosed(t *testing.T) { + secret := strings.Repeat("boundary-fixture-", 3) + for _, audience := range []any{"", " ", 123, nil, []string{"bmw:staging"}} { + m, ok := New().ModuleFactories()["auth.jwt"]("auth-jwt", map[string]any{ + "secret": secret, "issuer": "buymywishlist:staging", "audience": audience, + }).(*module.JWTAuthModule) + if !ok { + t.Fatal("factory returned the wrong module") + } + claims := jwt.MapClaims{"iss": "buymywishlist:staging", "exp": time.Now().Add(time.Hour).Unix()} + token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret)) + if err != nil { + t.Fatal("signing synthetic token failed") + } + valid, _, err := m.Authenticate(token) + if err != nil || valid { + t.Fatal("invalid configured audience selected legacy validation") + } + } +} diff --git a/plugins/auth/plugin.go b/plugins/auth/plugin.go index c84c9a90..2dd7a8f7 100644 --- a/plugins/auth/plugin.go +++ b/plugins/auth/plugin.go @@ -97,6 +97,10 @@ func (p *Plugin) ModuleFactories() map[string]plugin.ModuleFactory { issuer = iss } authMod := module.NewJWTAuthModule(name, secret, tokenExpiry, issuer) + if value, configured := cfg["audience"]; configured { + audience, _ := value.(string) + authMod.SetAudience(audience) + } if sf, ok := cfg["seedFile"].(string); ok && sf != "" { sf = config.ResolvePathInConfig(cfg, sf) authMod.SetSeedFile(sf) @@ -392,6 +396,7 @@ func (p *Plugin) ModuleSchemas() []*schema.ModuleSchema { {Key: "secret", Label: "JWT Secret", Type: schema.FieldTypeString, Required: true, Description: "Secret key for signing JWT tokens (supports $ENV_VAR expansion)", Placeholder: "$JWT_SECRET", Sensitive: true}, {Key: "tokenExpiry", Label: "Token Expiry", Type: schema.FieldTypeDuration, DefaultValue: "24h", Description: "Token expiration duration (e.g. 1h, 24h, 7d)", Placeholder: "24h"}, {Key: "issuer", Label: "Issuer", Type: schema.FieldTypeString, DefaultValue: "workflow", Description: "Token issuer claim", Placeholder: "workflow"}, + {Key: "audience", Label: "Audience", Type: schema.FieldTypeString, Description: "Token audience. When configured, must be a nonempty string; every JWT entry point requires this audience and the configured issuer."}, {Key: "seedFile", Label: "Seed Users File", Type: schema.FieldTypeString, Description: "Path to JSON file with initial user accounts", Placeholder: "data/users.json"}, {Key: "responseFormat", Label: "Response Format", Type: schema.FieldTypeSelect, Options: []string{"standard", "oauth2"}, Description: "Format of authentication response payloads"}, {Key: "allowRegistration", Label: "Allow Open Registration", Type: schema.FieldTypeBool, DefaultValue: false, Description: "When true, any visitor may register without admin intervention"}, diff --git a/schema/module_schema.go b/schema/module_schema.go index 4fffdf9d..5a34debe 100644 --- a/schema/module_schema.go +++ b/schema/module_schema.go @@ -637,6 +637,7 @@ func (r *ModuleSchemaRegistry) registerBuiltins() { {Key: "secret", Label: "JWT Secret", Type: FieldTypeString, Required: true, Description: "Secret key for signing JWT tokens (supports $ENV_VAR expansion)", Placeholder: "$JWT_SECRET", Sensitive: true}, {Key: "tokenExpiry", Label: "Token Expiry", Type: FieldTypeDuration, DefaultValue: "24h", Description: "Token expiration duration (e.g. 1h, 24h, 7d)", Placeholder: "24h"}, {Key: "issuer", Label: "Issuer", Type: FieldTypeString, DefaultValue: "workflow", Description: "Token issuer claim", Placeholder: "workflow"}, + {Key: "audience", Label: "Audience", Type: FieldTypeString, Description: "Token audience. When configured, must be a nonempty string; every JWT entry point requires this audience and the configured issuer."}, {Key: "seedFile", Label: "Seed Users File", Type: FieldTypeString, Description: "Path to JSON file with initial user accounts", Placeholder: "data/users.json"}, {Key: "responseFormat", Label: "Response Format", Type: FieldTypeSelect, Options: []string{"standard", "oauth2"}, Description: "Format of authentication response payloads"}, {Key: "allowRegistration", Label: "Allow Open Registration", Type: FieldTypeBool, DefaultValue: false, Description: "When true, any visitor may register without admin intervention"}, diff --git a/schema/module_schema_test.go b/schema/module_schema_test.go index 3979d87e..6398fb50 100644 --- a/schema/module_schema_test.go +++ b/schema/module_schema_test.go @@ -65,7 +65,7 @@ func TestModuleSchemaRegistry_ConfigFieldsMatchEngine(t *testing.T) { {"api.handler", []string{"resourceName", "workflowType", "workflowEngine", "initialTransition", "seedFile", "sourceResourceName", "stateFilter", "fieldMapping", "transitionMap", "summaryFields"}}, {"database.workflow", []string{"driver", "dsn", "maxOpenConns", "maxIdleConns"}}, {"messaging.kafka", []string{"brokers", "groupId"}}, - {"auth.jwt", []string{"secret", "tokenExpiry", "issuer", "seedFile", "responseFormat", "allowRegistration"}}, + {"auth.jwt", []string{"secret", "tokenExpiry", "issuer", "audience", "seedFile", "responseFormat", "allowRegistration"}}, {"static.fileserver", []string{"root", "prefix", "spaFallback", "cacheMaxAge", "router"}}, {"processing.step", []string{"componentId", "successTransition", "compensateTransition", "maxRetries", "retryBackoffMs", "timeoutSeconds"}}, {"http.middleware.securityheaders", []string{"contentSecurityPolicy", "frameOptions", "contentTypeOptions", "hstsMaxAge", "referrerPolicy", "permissionsPolicy"}}, diff --git a/schema/testdata/editor-schemas.golden.json b/schema/testdata/editor-schemas.golden.json index 3f1fd9b8..53828dc7 100644 --- a/schema/testdata/editor-schemas.golden.json +++ b/schema/testdata/editor-schemas.golden.json @@ -477,6 +477,12 @@ "defaultValue": "workflow", "placeholder": "workflow" }, + { + "key": "audience", + "label": "Audience", + "type": "string", + "description": "Token audience. When configured, must be a nonempty string; every JWT entry point requires this audience and the configured issuer." + }, { "key": "seedFile", "label": "Seed Users File", From 2e31c39efc6d8d70189a64161cb918037f52ac6f Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Thu, 8 Oct 2026 17:21:11 -0400 Subject: [PATCH 2/4] ci: scope v0.74 maintenance validation and release effects --- .github/workflows/create-release.yml | 107 -------------- .github/workflows/maintenance-jwt.yml | 55 ++++++++ .github/workflows/release.yml | 195 +++++++++++--------------- 3 files changed, 136 insertions(+), 221 deletions(-) delete mode 100644 .github/workflows/create-release.yml create mode 100644 .github/workflows/maintenance-jwt.yml diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml deleted file mode 100644 index 0752964a..00000000 --- a/.github/workflows/create-release.yml +++ /dev/null @@ -1,107 +0,0 @@ -name: Create Release Tag - -# Manually trigger a new semantic version tag and immediately kick off the -# release workflow (release.yml) via workflow_call. GitHub Actions does not -# fire push-tag events for tags created by GITHUB_TOKEN, so we call the -# release workflow directly instead of relying on that trigger. - -on: - workflow_dispatch: - inputs: - bump: - description: 'Version bump type' - required: true - default: patch - type: choice - options: - - patch - - minor - - major - -permissions: - contents: write - -jobs: - tag: - name: Generate and push next semver tag - runs-on: ubuntu-latest - outputs: - tag: ${{ steps.next_version.outputs.tag }} - release_tag: ${{ steps.next_version.outputs.tag }} - - steps: - - name: Check out code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - fetch-depth: 0 - - - name: Determine next version - id: next_version - run: | - # Find the latest v* semver tag (ignore pre-release tags like snapshot) - LATEST=$(git tag --list 'v*' --sort=-version:refname \ - | grep -E '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' \ - | head -n1) - - if [ -z "$LATEST" ]; then - LATEST="v0.0.0" - fi - - echo "Latest tag: ${LATEST}" - - # Strip the leading 'v' - VERSION="${LATEST#v}" - MAJOR="${VERSION%%.*}" - MINOR="${VERSION#*.}"; MINOR="${MINOR%.*}" - PATCH="${VERSION##*.}" - - BUMP="${{ inputs.bump }}" - case "${BUMP}" in - major) - MAJOR=$((MAJOR + 1)) - MINOR=0 - PATCH=0 - ;; - minor) - MINOR=$((MINOR + 1)) - PATCH=0 - ;; - patch) - PATCH=$((PATCH + 1)) - ;; - esac - - NEXT_TAG="v${MAJOR}.${MINOR}.${PATCH}" - echo "Next tag: ${NEXT_TAG}" - echo "tag=${NEXT_TAG}" >> "$GITHUB_OUTPUT" - - - name: Create and push tag - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git tag "${{ steps.next_version.outputs.tag }}" - git push origin "${{ steps.next_version.outputs.tag }}" - - - name: Write release metadata - run: | - mkdir -p release-metadata - printf '%s\n' "${{ steps.next_version.outputs.tag }}" > release-metadata/release-tag.txt - - - name: Upload release metadata - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: release-metadata - path: release-metadata/release-tag.txt - if-no-files-found: error - - release: - name: Build and publish release - needs: tag - uses: ./.github/workflows/release.yml - with: - tag_name: ${{ needs.tag.outputs.release_tag }} - secrets: - repo_dispatch_token: ${{ secrets.repo_dispatch_token }} - permissions: - contents: write - packages: write diff --git a/.github/workflows/maintenance-jwt.yml b/.github/workflows/maintenance-jwt.yml new file mode 100644 index 00000000..56006c2c --- /dev/null +++ b/.github/workflows/maintenance-jwt.yml @@ -0,0 +1,55 @@ +name: Maintenance JWT + +on: + pull_request: + branches: ['maintenance/v0.74'] + push: + branches: ['maintenance/v0.74'] + +permissions: + contents: read + packages: read + +defaults: + run: + shell: bash + +env: + GOWORK: 'off' + GOMAXPROCS: '2' + WFCTL_DIFFCACHE: ':memory:' + +jobs: + jwt: + name: JWT maintenance regressions + runs-on: ubuntu-latest + steps: + - name: Check out exact candidate + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: go.mod + cache: false + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '24' + registry-url: 'https://npm.pkg.github.com' + cache: 'npm' + cache-dependency-path: ui/package-lock.json + - name: Build UI assets required for Go embed + env: + NODE_AUTH_TOKEN: ${{ github.token }} + run: | + cd ui && npm ci && npm run build + cd .. && mkdir -p module/ui_dist && cp -r ui/dist/* module/ui_dist/ + - name: Check JWT environment isolation + run: | + go test -p 2 -race ./module -run '^(TestJWTAuthEnvironmentBoundary|TestAuthValidateStepJWTEnvironmentBoundary)' -count=1 + go test -p 2 -race ./plugins/auth -run '^TestJWTFactory(EnvironmentBoundary|InvalidAudienceFailsClosed)$' -count=1 + go test -p 2 -race ./schema -run '^(TestModuleSchemaRegistry_ConfigFieldsMatchEngine|TestEditorSchemasGoldenFile|TestBuiltinGrammarSweep)$' -count=1 + - name: Check affected packages with race detection + run: go test -p 2 -race ./module ./plugins/auth ./schema -count=1 + - name: Verify committed dependency files remain unchanged + run: git diff --exit-code -- go.mod go.sum diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cc1859e3..e23a3fac 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,35 +1,52 @@ -name: Release +name: Maintenance Release (v0.74) on: push: tags: - - 'v*' - workflow_call: - inputs: - tag_name: - description: 'Tag name to release (e.g. v1.2.3)' - required: true - type: string - secrets: - repo_dispatch_token: - required: true + - 'v0.74.*' permissions: - contents: write - packages: write + contents: read + +defaults: + run: + shell: bash env: - GOPRIVATE: github.com/GoCodeAlone/* - GONOSUMCHECK: github.com/GoCodeAlone/* - TAG_NAME: ${{ inputs.tag_name || github.ref_name }} + TAG_NAME: ${{ github.ref_name }} # See ci.yml for rationale. Force in-memory diffcache in CI per the # T3.5 rev3 lifecycle constraint. WFCTL_DIFFCACHE: ":memory:" jobs: + verify-release-tag: + name: Verify tag belongs to protected main + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Check out tagged commit + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + fetch-depth: 0 + - name: Verify maintenance tag and checked-out source + run: | + set -euo pipefail + [[ "$TAG_NAME" =~ ^v0\.74\.(0|[1-9][0-9]*)$ ]] + [[ "$(git rev-parse --verify "refs/tags/${TAG_NAME}^{commit}")" == "$GITHUB_SHA" ]] + [[ "$(git rev-parse HEAD)" == "$GITHUB_SHA" ]] + - name: Require tagged commit on protected main + run: | + git fetch --no-tags origin refs/heads/main:refs/remotes/origin/main + git merge-base --is-ancestor "$GITHUB_SHA" refs/remotes/origin/main + test: name: Pre-release Tests runs-on: ubuntu-latest + needs: verify-release-tag + permissions: + contents: read + packages: read steps: - name: Check out code uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 @@ -37,7 +54,7 @@ jobs: - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: '1.26.4' + go-version: '1.26.5' cache: false - name: Set up Node.js @@ -49,11 +66,11 @@ jobs: cache-dependency-path: ui/package-lock.json - name: Build UI assets (required for Go embed) + env: + NODE_AUTH_TOKEN: ${{ github.token }} run: | cd ui && npm ci && npm run build cd .. && mkdir -p module/ui_dist && cp -r ui/dist/* module/ui_dist/ - env: - NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Run tests run: go test -race ./... @@ -61,7 +78,10 @@ jobs: build-ui: name: Build Admin UI runs-on: ubuntu-latest - needs: test + needs: verify-release-tag + permissions: + contents: read + packages: read steps: - name: Check out code @@ -76,9 +96,9 @@ jobs: cache-dependency-path: ui/package-lock.json - name: Install dependencies - run: cd ui && npm ci env: - NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NODE_AUTH_TOKEN: ${{ github.token }} + run: cd ui && npm ci - name: Build admin UI run: cd ui && npm run build @@ -87,7 +107,6 @@ jobs: run: | VERSION=${TAG_NAME} tar -czf "workflow-admin-ui-${VERSION}.tar.gz" -C ui dist - echo "UI_TARBALL=workflow-admin-ui-${VERSION}.tar.gz" >> "$GITHUB_ENV" - name: Upload UI artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -113,7 +132,7 @@ jobs: - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: '1.26.4' + go-version: '1.26.5' cache: false - name: Set up Node.js @@ -125,35 +144,28 @@ jobs: cache-dependency-path: ui/package-lock.json - name: Build UI assets (required for Go embed) + env: + NODE_AUTH_TOKEN: ${{ github.token }} run: | cd ui && npm ci && npm run build cd .. && mkdir -p module/ui_dist && cp -r ui/dist/* module/ui_dist/ - env: - NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Set up ko uses: ko-build/setup-ko@d006021bd0c28d1ce33a07e7943d48b079944c8d # v0.9 - name: Log in to GitHub Container Registry - run: echo "${{ secrets.GITHUB_TOKEN }}" | ko login ghcr.io --username ${{ github.actor }} --password-stdin + run: echo "${{ github.token }}" | ko login ghcr.io --username ${{ github.actor }} --password-stdin - name: Build and push - env: - GOPRIVATE: github.com/GoCodeAlone/* - GONOSUMCHECK: github.com/GoCodeAlone/* - GOFLAGS: -mod=mod run: | KO_DOCKER_REPO="ghcr.io/$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" export KO_DOCKER_REPO VERSION="${TAG_NAME#v}" - CORE_VERSION="${VERSION%%[-+]*}" - MAJOR_MINOR="$(echo "$CORE_VERSION" | cut -d. -f1-2)" - SHA_SHORT="${GITHUB_SHA:0:7}" ko build ./cmd/server \ --bare \ --platform=linux/amd64,linux/arm64 \ - --tags="${TAG_NAME},${VERSION},${MAJOR_MINOR},sha-${SHA_SHORT}" \ + --tags="${TAG_NAME},${VERSION},sha-${GITHUB_SHA}" \ --sbom=spdx \ --image-refs=/tmp/image-refs.txt @@ -163,7 +175,10 @@ jobs: build-binaries: name: Build ${{ matrix.name }} binaries runs-on: ubuntu-latest - needs: test + needs: verify-release-tag + permissions: + contents: read + packages: read strategy: fail-fast: false matrix: @@ -194,7 +209,7 @@ jobs: - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: '1.26.4' + go-version: '1.26.5' cache: false - name: Set up Node.js @@ -208,18 +223,16 @@ jobs: - name: Build UI assets (required for Go embed) if: matrix.build_ui + env: + NODE_AUTH_TOKEN: ${{ github.token }} run: | cd ui && npm ci && npm run build cd .. && mkdir -p module/ui_dist && cp -r ui/dist/* module/ui_dist/ - env: - NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Build binaries run: | mkdir -p dist VERSION=${TAG_NAME} - LDFLAGS="-s -w -X main.version=${VERSION}" - for platform in ${{ matrix.platforms }}; do GOOS="${platform%/*}" GOARCH="${platform#*/}" @@ -228,7 +241,7 @@ jobs: output="${output}.exe" fi echo "Building ${output}..." - GOOS="${GOOS}" GOARCH="${GOARCH}" go build -ldflags="${LDFLAGS}" -o "${output}" ${{ matrix.cmd }} + GOOS="${GOOS}" GOARCH="${GOARCH}" go build -ldflags="-s -w -X main.version=${VERSION}" -o "${output}" ${{ matrix.cmd }} done - name: Upload binaries @@ -241,7 +254,13 @@ jobs: release: name: Create GitHub Release runs-on: ubuntu-latest - needs: [build-binaries, build-ui] + # Build temporary release artifacts in parallel with the pre-release test + # job, but do not publish the GitHub release until tests and artifacts have + # completed. The Docker job still waits for tests because it pushes to GHCR + # directly rather than uploading a temporary artifact. + needs: [test, build-binaries, build-ui] + permissions: + contents: write steps: - name: Check out code @@ -265,7 +284,7 @@ jobs: - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: '1.26.4' + go-version: '1.26.5' cache: false - name: Generate grpc-versions.txt @@ -292,78 +311,26 @@ jobs: - name: Create checksums run: | cd dist - sha256sum ./* > checksums.txt + sha256sum ./* | sed 's# \./# #' > checksums.txt - name: Create Release (draft during asset upload) - uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 - with: - tag_name: ${{ env.TAG_NAME }} - files: | - dist/* - draft: true - prerelease: ${{ contains(env.TAG_NAME, '-') }} - generate_release_notes: true + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + prerelease_flag=() + if [[ "${TAG_NAME}" == *-* ]]; then + prerelease_flag+=(--prerelease) + fi + gh release create "${TAG_NAME}" dist/* \ + --repo "${GITHUB_REPOSITORY}" \ + --draft \ + --latest=false \ + --verify-tag \ + --generate-notes \ + "${prerelease_flag[@]}" - name: Publish release (was draft during asset upload) env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh release edit ${{ env.TAG_NAME }} --draft=false --repo ${{ github.repository }} - - update-homebrew: - name: Update Homebrew Tap - runs-on: ubuntu-latest - needs: release - if: ${{ !contains(inputs.tag_name || github.ref_name, '-') }} - steps: - - name: Update wfctl formula in homebrew-tap - uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1 - with: - token: ${{ secrets.repo_dispatch_token }} - repository: GoCodeAlone/homebrew-tap - event-type: update-wfctl - client-payload: '{"version": "${{ env.TAG_NAME }}"}' - - notify-ide-plugins: - name: Notify IDE Plugins - runs-on: ubuntu-latest - needs: release - if: ${{ !contains(inputs.tag_name || github.ref_name, '-') }} - strategy: - matrix: - repo: ['GoCodeAlone/workflow-editor'] - steps: - - name: Trigger IDE update for ${{ matrix.repo }} - uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1 - with: - token: ${{ secrets.repo_dispatch_token }} - repository: ${{ matrix.repo }} - event-type: workflow-release - client-payload: '{"version": "${{ env.TAG_NAME }}"}' - - notify-workflow-registry: - name: Notify workflow-registry - runs-on: ubuntu-latest - needs: release - if: ${{ !contains(inputs.tag_name || github.ref_name, '-') }} - steps: - - name: Trigger registry manifest sync - uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1 - with: - token: ${{ secrets.repo_dispatch_token }} - repository: GoCodeAlone/workflow-registry - event-type: workflow-release - client-payload: '{"workflow": "${{ github.repository }}", "version": "${{ env.TAG_NAME }}"}' - - notify-workflow-scenarios: - name: Notify workflow-scenarios - runs-on: ubuntu-latest - needs: release - if: ${{ !contains(inputs.tag_name || github.ref_name, '-') }} - steps: - - name: Trigger workflow-scenarios bump - uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1 - with: - token: ${{ secrets.repo_dispatch_token }} - repository: GoCodeAlone/workflow-scenarios - event-type: workflow-release - client-payload: '{"workflow": "${{ github.repository }}", "tag": "${{ env.TAG_NAME }}"}' + GH_TOKEN: ${{ github.token }} + run: gh release edit "${TAG_NAME}" --draft=false --latest=false --repo "$GITHUB_REPOSITORY" From 2cc5f1b5e3e54d98c9fe5c7e3d8ef6b2ad44172a Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Thu, 8 Oct 2026 17:29:10 -0400 Subject: [PATCH 3/4] ci: bind maintenance validation to exact PR source --- .github/workflows/maintenance-jwt.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/maintenance-jwt.yml b/.github/workflows/maintenance-jwt.yml index 56006c2c..9aeb48f1 100644 --- a/.github/workflows/maintenance-jwt.yml +++ b/.github/workflows/maintenance-jwt.yml @@ -26,6 +26,8 @@ jobs: steps: - name: Check out exact candidate uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: From 67817c5037e5fbb8a45e33f5d64cd86fa4aea8cd Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Thu, 8 Oct 2026 18:09:07 -0400 Subject: [PATCH 4/4] fix(release): admit exact reviewed v0.74.8 maintenance source --- .github/workflows/maintenance-jwt.yml | 2 + .github/workflows/release.yml | 10 +- docs/MAINTENANCE_RELEASE.md | 44 ++++ scripts/test-verify-maintenance-release.py | 281 +++++++++++++++++++++ scripts/verify-maintenance-release.py | 186 ++++++++++++++ 5 files changed, 519 insertions(+), 4 deletions(-) create mode 100644 docs/MAINTENANCE_RELEASE.md create mode 100644 scripts/test-verify-maintenance-release.py create mode 100644 scripts/verify-maintenance-release.py diff --git a/.github/workflows/maintenance-jwt.yml b/.github/workflows/maintenance-jwt.yml index 9aeb48f1..b3fa0611 100644 --- a/.github/workflows/maintenance-jwt.yml +++ b/.github/workflows/maintenance-jwt.yml @@ -28,6 +28,8 @@ jobs: uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} + - name: Check maintenance release admission boundaries + run: python3 scripts/test-verify-maintenance-release.py - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e23a3fac..35cac410 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,10 +20,11 @@ env: jobs: verify-release-tag: - name: Verify tag belongs to protected main + name: Verify protected release provenance runs-on: ubuntu-latest permissions: contents: read + actions: read steps: - name: Check out tagged commit uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 @@ -35,10 +36,11 @@ jobs: [[ "$TAG_NAME" =~ ^v0\.74\.(0|[1-9][0-9]*)$ ]] [[ "$(git rev-parse --verify "refs/tags/${TAG_NAME}^{commit}")" == "$GITHUB_SHA" ]] [[ "$(git rev-parse HEAD)" == "$GITHUB_SHA" ]] - - name: Require tagged commit on protected main + - name: Require main ancestry or exact reviewed v0.74.8 admission + env: + GH_TOKEN: ${{ github.token }} run: | - git fetch --no-tags origin refs/heads/main:refs/remotes/origin/main - git merge-base --is-ancestor "$GITHUB_SHA" refs/remotes/origin/main + python3 scripts/verify-maintenance-release.py test: name: Pre-release Tests diff --git a/docs/MAINTENANCE_RELEASE.md b/docs/MAINTENANCE_RELEASE.md new file mode 100644 index 00000000..9701111d --- /dev/null +++ b/docs/MAINTENANCE_RELEASE.md @@ -0,0 +1,44 @@ +# One-time v0.74.8 release admission + +The normal protected-main ancestry guard remains. Its only exception is the +reviewed JWT backport in PR #1041, released as `v0.74.8` from `maintenance/v0.74`. +No repository protection or merge setting needs to change. + +1. Merge the equivalent JWT fix to main through its reviewed PR. Its auth patch + must equal the reviewed backport (`50ca17cb77067ddc36b5583bb54fc62a4f7cc5db`). +2. Complete exact-head Maintenance JWT CI and merge PR #1041 to + `maintenance/v0.74`. Record the reviewed PR head, successful CI run and actual + merge commit. Squash is supported only when the merge tree exactly equals + the reviewed source tree. +3. Open a separate main PR adding + `.github/release-admissions/v0.74.8.json` with these fields: + + ```json + { + "schema": 1, + "tag": "v0.74.8", + "release_commit": "", + "source_commit": "", + "release_tree": "", + "base_commit": "f03ce511ea6fbc5daab4f9b828bc3c2cb762fa43", + "source_pr": 1041, + "source_ci_run": 0, + "upstream_pr": 0, + "upstream_commit": "" + } + ``` + + Replace placeholders and zero identifiers with the exact evidence. Review + the full maintenance source and CI provenance before merging this record. + Placeholders are deliberately invalid. A record in the tagged source, a PR + branch or an unmerged main PR cannot authorize release. +4. After owner-authorized record merge and tag publication, the release guard + fetches main, the maintained branch and PR1041 source; verifies both merged + PRs, base ancestry, identical trees, the exact JWT patch and successful + source CI; and admits only that recorded commit. Missing records, unavailable + GitHub reads or mismatches deny release before tests/builds/publication. + +No tag is created by this change. Tagging before admission will fail. The +existing release tests and artifact gates remain; maintenance images use only +exact version/full-SHA tags, GitHub latest stays false, and downstream broadcasts +and moving aliases remain absent. Other tags gain no maintenance exception. diff --git a/scripts/test-verify-maintenance-release.py b/scripts/test-verify-maintenance-release.py new file mode 100644 index 00000000..c1861ee6 --- /dev/null +++ b/scripts/test-verify-maintenance-release.py @@ -0,0 +1,281 @@ +#!/usr/bin/env python3 +"""Exercise real Git lineage with a local origin and modeled read-only GitHub API.""" + +import copy +import importlib.util +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + + +class ReleaseAdmissionTests(unittest.TestCase): + def setUp(self): + spec = importlib.util.spec_from_file_location( + "maintenance_guard_fixture", Path(__file__).with_name("verify-maintenance-release.py")) + self.guard = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = self.guard + spec.loader.exec_module(self.guard) + self.temp = tempfile.TemporaryDirectory(prefix="maintenance-release-guard-") + self.addCleanup(self.temp.cleanup) + self.repo = Path(self.temp.name) / "work" + self.origin = Path(self.temp.name) / "origin.git" + subprocess.run(["git", "init", "--bare", "-q", str(self.origin)], check=True) + subprocess.run(["git", "init", "-q", "-b", "main", str(self.repo)], check=True) + self.run_git("config", "user.name", "Guard fixture") + self.run_git("config", "user.email", "guard@example.invalid") + self.run_git("remote", "add", "origin", str(self.origin)) + original_command = self.guard.command + + def fixture_command(args, **kwargs): + self.assertEqual(args[0], "git") + return original_command(["git", "-C", str(self.repo), *args[1:]], **kwargs) + + self.guard.command = fixture_command + for name in self.guard.AUTH_PATHS: + file = self.repo / name + file.parent.mkdir(parents=True, exist_ok=True) + file.write_text("baseline\n") + self.commit("base") + self.base = self.rev("HEAD") + self.run_git("tag", "v0.74.7") + self.run_git("checkout", "-q", "-b", "source") + (self.repo / "module/jwt_auth.go").write_text("baseline\nissuer and audience boundary\n") + self.commit("reviewed backport") + self.source = self.rev("HEAD") + self.tree = self.rev("HEAD^{tree}") + self.patch = self.guard.patch_id(self.base, self.source) + self.policy = self.guard.Policy(base_commit=self.base, auth_patch_id=self.patch) + self.run_git("checkout", "-q", "-b", "maintenance/v0.74", self.base) + self.run_git("merge", "--squash", "source") + self.commit("maintenance squash") + self.release = self.rev("HEAD") + self.assertEqual(self.rev("HEAD^{tree}"), self.tree) + self.run_git("tag", "v0.74.8") + self.run_git("checkout", "-q", "main") + self.run_git("merge", "--squash", "source") + self.commit("upstream JWT fix") + self.upstream = self.rev("HEAD") + self.record = { + "schema": 1, "tag": "v0.74.8", "release_commit": self.release, + "source_commit": self.source, "release_tree": self.tree, + "base_commit": self.base, "source_pr": 1041, "source_ci_run": 42, + "upstream_pr": 1042, "upstream_commit": self.upstream, + } + self.write_record(self.record) + self.run_git("push", "-q", "origin", "main", "maintenance/v0.74", + "source:refs/pull/1041/head", "refs/tags/v0.74.7", "refs/tags/v0.74.8") + repository = {"full_name": self.guard.REPOSITORY} + self.responses = { + "pulls/1041": {"number": 1041, "merged": True, "merge_commit_sha": self.release, + "base": {"ref": "maintenance/v0.74", "repo": repository}, + "head": {"sha": self.source, "repo": repository}}, + "pulls/1042": {"number": 1042, "merged": True, "merge_commit_sha": self.upstream, + "base": {"ref": "main", "repo": repository}, + "head": {"sha": self.source, "repo": repository}}, + "actions/runs/42": {"id": 42, "repository": repository, + "path": ".github/workflows/maintenance-jwt.yml", + "head_sha": self.source, "event": "pull_request", + "status": "completed", "conclusion": "success", + "pull_requests": [{"number": 1041}]}, + } + self.env = {"TAG_NAME": "v0.74.8", "GITHUB_SHA": self.release, + "GITHUB_REPOSITORY": self.guard.REPOSITORY, + "GITHUB_EVENT_NAME": "push", "GITHUB_REF": "refs/tags/v0.74.8"} + self.run_git("checkout", "-q", "--detach", self.release) + + def run_git(self, *args): + result = subprocess.run(["git", "-C", str(self.repo), *args], capture_output=True, check=True) + return result.stdout.decode().strip() + + def rev(self, ref): + return self.run_git("rev-parse", ref) + + def commit(self, message): + self.run_git("add", "-A") + self.run_git("commit", "-q", "-m", message) + + def write_record(self, record, raw=None): + self.run_git("checkout", "-q", "main") + path = self.repo / ".github/release-admissions/v0.74.8.json" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(raw if raw is not None else json.dumps(record)) + self.commit("reviewed main admission") + + def update_record(self, record, raw=None): + self.write_record(record, raw) + self.run_git("push", "-q", "origin", "main") + self.run_git("checkout", "-q", "--detach", self.release) + + def api(self, path): + return copy.deepcopy(self.responses[path]) + + def verify(self): + return self.guard.verify_release(self.env, self.api, self.policy) + + def test_reviewed_squash_release_with_exact_source_ci(self): + self.assertNotEqual(self.release, self.source) + self.assertEqual(self.verify(), "reviewed-v0.74.8") + + def test_main_ancestor_keeps_existing_admission_without_api(self): + self.run_git("checkout", "-q", "--detach", self.upstream) + self.run_git("tag", "-f", "v0.74.8") + self.env["GITHUB_SHA"] = self.upstream + self.assertEqual(self.guard.verify_release( + self.env, lambda _: self.fail("main ancestry must not need API"), self.policy), "protected-main") + + def test_record_in_tagged_source_cannot_self_authorize(self): + self.run_git("checkout", "-q", "main") + self.run_git("rm", "-q", ".github/release-admissions/v0.74.8.json") + self.commit("remove main admission") + self.run_git("push", "-q", "origin", "main") + self.run_git("checkout", "-q", "--detach", self.release) + path = self.repo / ".github/release-admissions/v0.74.8.json" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(self.record)) + self.commit("self-authorizing tag record") + self.env["GITHUB_SHA"] = self.rev("HEAD") + self.run_git("tag", "-f", "v0.74.8") + with self.assertRaisesRegex(self.guard.Denied, "no regular admission"): + self.verify() + + def test_missing_main_record_denies(self): + self.run_git("checkout", "-q", "main") + self.run_git("rm", "-q", ".github/release-admissions/v0.74.8.json") + self.commit("remove admission") + self.run_git("push", "-q", "origin", "main") + self.run_git("checkout", "-q", "--detach", self.release) + with self.assertRaises(self.guard.Denied): + self.verify() + + def test_record_changes_cannot_admit_other_commit_base_or_pr(self): + for field, value in [("tag", "v0.74.9"), ("release_commit", self.source), + ("base_commit", self.upstream), ("source_pr", 1043), + ("release_tree", self.base), ("source_commit", self.upstream)]: + with self.subTest(field=field): + record = dict(self.record, **{field: value}) + self.update_record(record) + with self.assertRaises(self.guard.Denied): + self.verify() + + def test_other_tag_has_no_exception(self): + self.run_git("tag", "v0.74.9", self.release) + self.env.update(TAG_NAME="v0.74.9", GITHUB_REF="refs/tags/v0.74.9") + with self.assertRaisesRegex(self.guard.Denied, "No exception"): + self.verify() + + def test_bad_event_tag_head_and_repository_deny(self): + original = dict(self.env) + for field, value in [("GITHUB_EVENT_NAME", "workflow_dispatch"), + ("GITHUB_REPOSITORY", "attacker/workflow"), + ("GITHUB_REF", "refs/heads/main"), + ("TAG_NAME", "v0.74.08"), ("GITHUB_SHA", self.source)]: + with self.subTest(field=field): + self.env = dict(original, **{field: value}) + with self.assertRaises(self.guard.Denied): + self.verify() + + def test_source_and_upstream_must_be_merged_into_exact_branches(self): + original = copy.deepcopy(self.responses) + for key in ["pulls/1041", "pulls/1042"]: + for change in ["unmerged", "wrong-branch", "wrong-repository", "wrong-merge"]: + with self.subTest(key=key, change=change): + self.responses = copy.deepcopy(original) + pr = self.responses[key] + if change == "unmerged": + pr["merged"] = False + elif change == "wrong-branch": + pr["base"]["ref"] = "unreviewed" + elif change == "wrong-repository": + pr["base"]["repo"]["full_name"] = "attacker/workflow" + else: + pr["merge_commit_sha"] = self.base + with self.assertRaises(self.guard.Denied): + self.verify() + + def test_ci_must_be_successful_exact_source_and_workflow(self): + original = copy.deepcopy(self.responses["actions/runs/42"]) + for field, value in [("conclusion", "failure"), ("status", "in_progress"), + ("head_sha", self.release), ("event", "push"), + ("path", ".github/workflows/unrelated.yml"), + ("pull_requests", [{"number": 1042}]), ("id", 43), + ("repository", {"full_name": "attacker/workflow"})]: + with self.subTest(field=field): + self.responses["actions/runs/42"] = dict(original, **{field: value}) + with self.assertRaises(self.guard.Denied): + self.verify() + + def test_duplicate_unknown_boolean_and_symlink_records_deny(self): + for record, raw in [(dict(self.record, source_pr=True), None), + (dict(self.record, extra="unexpected"), None), + (self.record, '{"schema":1,"schema":1}')]: + self.update_record(record, raw) + with self.assertRaises(self.guard.Denied): + self.verify() + self.run_git("checkout", "-q", "main") + path = self.repo / ".github/release-admissions/v0.74.8.json" + path.unlink() + path.symlink_to("../../record.json") + self.commit("symlink admission") + self.run_git("push", "-q", "origin", "main") + self.run_git("checkout", "-q", "--detach", self.release) + with self.assertRaisesRegex(self.guard.Denied, "no regular admission"): + self.verify() + + def test_git_or_api_failure_cannot_fall_back_to_acceptance(self): + def unavailable(_): + raise self.guard.Denied("GitHub provenance read unavailable") + with self.assertRaises(self.guard.Denied): + self.guard.verify_release(self.env, unavailable, self.policy) + self.run_git("remote", "set-url", "origin", str(self.repo / "missing-origin")) + with self.assertRaises(self.guard.Denied): + self.verify() + + def test_wrong_auth_patch_fails_even_with_main_admission(self): + wrong = self.guard.Policy(base_commit=self.base, auth_patch_id="0" * 40) + with self.assertRaisesRegex(self.guard.Denied, "JWT patch mismatch"): + self.guard.verify_release(self.env, self.api, wrong) + + def test_release_content_cannot_change_after_reviewed_source(self): + (self.repo / "unreviewed.txt").write_text("changed after source CI\n") + self.commit("unreviewed release change") + changed = self.rev("HEAD") + self.run_git("update-ref", "refs/heads/maintenance/v0.74", changed) + self.run_git("push", "-q", "origin", "maintenance/v0.74") + self.run_git("tag", "-f", "v0.74.8", changed) + self.release = changed + self.env["GITHUB_SHA"] = changed + self.responses["pulls/1041"]["merge_commit_sha"] = changed + self.update_record(dict(self.record, release_commit=changed)) + with self.assertRaisesRegex(self.guard.Denied, "tree differs"): + self.verify() + + def test_source_ref_cannot_move_after_main_admission(self): + self.run_git("push", "-q", "--force", "origin", self.release + ":refs/pull/1041/head") + with self.assertRaisesRegex(self.guard.Denied, "source moved"): + self.verify() + + def test_upstream_must_actually_be_reachable_from_main(self): + self.responses["pulls/1042"]["merge_commit_sha"] = self.release + self.update_record(dict(self.record, upstream_commit=self.release)) + with self.assertRaisesRegex(self.guard.Denied, "not on protected main"): + self.verify() + + def test_release_must_actually_be_on_maintained_branch(self): + self.run_git("push", "-q", "--force", "origin", self.base + ":refs/heads/maintenance/v0.74") + with self.assertRaisesRegex(self.guard.Denied, "lineage mismatch"): + self.verify() + + def test_local_replace_refs_cannot_fabricate_main_ancestry(self): + main = self.rev("refs/heads/main") + fake = self.run_git("commit-tree", self.rev(main + "^{tree}"), "-p", self.release, + "-m", "fabricated ancestry fixture") + self.run_git("replace", main, fake) + self.run_git("merge-base", "--is-ancestor", self.release, main) + self.assertEqual(self.verify(), "reviewed-v0.74.8") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/scripts/verify-maintenance-release.py b/scripts/verify-maintenance-release.py new file mode 100644 index 00000000..167b2a45 --- /dev/null +++ b/scripts/verify-maintenance-release.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +"""Keep main ancestry, with one main-approved, exact v0.74.8 admission.""" + +import json +import os +import re +import subprocess +from dataclasses import dataclass + + +REPOSITORY = "GoCodeAlone/workflow" +AUTH_PATHS = ( + "DOCUMENTATION.md", + "module/jwt_auth.go", + "module/jwt_auth_boundary_test.go", + "plugins/auth/jwt_boundary_test.go", + "plugins/auth/plugin.go", + "schema/module_schema.go", + "schema/module_schema_test.go", + "schema/testdata/editor-schemas.golden.json", +) + + +@dataclass(frozen=True) +class Policy: + base_commit: str = "f03ce511ea6fbc5daab4f9b828bc3c2cb762fa43" + auth_patch_id: str = "50ca17cb77067ddc36b5583bb54fc62a4f7cc5db" + + +class Denied(Exception): + pass + + +def command(args, data=None, allowed=(0,)): + try: + result = subprocess.run(args, input=data, capture_output=True, timeout=45, + env=dict(os.environ, GIT_NO_REPLACE_OBJECTS="1")) + except (OSError, subprocess.TimeoutExpired) as error: + raise Denied("Provenance command unavailable") from error + if result.returncode not in allowed: + raise Denied("Provenance command failed") + return result.returncode, result.stdout + + +def git(*args, data=None, allowed=(0,)): + return command(["git", *args], data=data, allowed=allowed) + + +def github_api(path): + _, raw = command(["gh", "api", f"repos/{REPOSITORY}/{path}"]) + try: + return json.loads(raw) + except (ValueError, UnicodeError) as error: + raise Denied("GitHub provenance response invalid") from error + + +def require(condition, message): + if not condition: + raise Denied(message) + + +def oid(value): + return isinstance(value, str) and re.fullmatch(r"[0-9a-f]{40}", value) is not None + + +def git_text(*args): + return git(*args)[1].decode("ascii").strip() + + +def ancestor(commit, reference): + status, _ = git("merge-base", "--is-ancestor", commit, reference, allowed=(0, 1)) + return status == 0 + + +def patch_id(base, commit): + _, patch = git("diff", "--no-ext-diff", "--no-textconv", base, commit, "--", *AUTH_PATHS) + value = git("patch-id", "--stable", data=patch)[1].decode("ascii").split() + return value[0] if len(value) == 2 else "" + + +def admission_record(): + path = ".github/release-admissions/v0.74.8.json" + entry = git("ls-tree", "-z", "refs/remotes/origin/main", "--", path)[1] + require(re.fullmatch(rb"100644 blob [0-9a-f]{40}\t" + re.escape(path.encode()) + rb"\x00", entry), + "Protected main has no regular admission record") + raw = git("show", f"refs/remotes/origin/main:{path}")[1] + require(len(raw) <= 4096, "Admission record is too large") + + def unique(pairs): + result = {} + for key, value in pairs: + require(key not in result, "Admission record has duplicate keys") + result[key] = value + return result + + try: + record = json.loads(raw, object_pairs_hook=unique) + except (ValueError, UnicodeError) as error: + raise Denied("Admission record JSON invalid") from error + keys = {"schema", "tag", "release_commit", "source_commit", "release_tree", + "base_commit", "source_pr", "source_ci_run", "upstream_pr", "upstream_commit"} + require(type(record) is dict and set(record) == keys, "Admission record schema invalid") + require(type(record["schema"]) is int and record["schema"] == 1, + "Admission record version invalid") + for field in ("release_commit", "source_commit", "release_tree", "base_commit", "upstream_commit"): + require(oid(record[field]), "Admission record commit or tree invalid") + for field in ("source_pr", "source_ci_run", "upstream_pr"): + require(type(record[field]) is int and record[field] > 0, "Admission record identifier invalid") + return record + + +def merged_pr(api, number, branch): + pr = api(f"pulls/{number}") + require(type(pr) is dict and pr.get("number") == number and pr.get("merged") is True, + "Required pull request is not merged") + require(pr.get("base", {}).get("ref") == branch + and pr.get("base", {}).get("repo", {}).get("full_name") == REPOSITORY + and pr.get("head", {}).get("repo", {}).get("full_name") == REPOSITORY, + "Pull request repository or branch mismatch") + return pr + + +def verify_release(env, api=github_api, policy=Policy()): + tag, commit = env.get("TAG_NAME", ""), env.get("GITHUB_SHA", "") + require(env.get("GITHUB_REPOSITORY") == REPOSITORY + and env.get("GITHUB_EVENT_NAME") == "push" + and env.get("GITHUB_REF") == f"refs/tags/{tag}", "Release event context invalid") + require(re.fullmatch(r"v0\.74\.(0|[1-9][0-9]*)", tag) and oid(commit), + "Maintenance tag or commit invalid") + require(git_text("rev-parse", "--verify", f"refs/tags/{tag}^{{commit}}") == commit + and git_text("rev-parse", "HEAD") == commit, "Tag and checked-out source mismatch") + git("fetch", "--no-tags", "origin", "refs/heads/main:refs/remotes/origin/main") + if ancestor(commit, "refs/remotes/origin/main"): + return "protected-main" + + require(tag == "v0.74.8", "No exception for this maintenance tag") + record = admission_record() + require(record["tag"] == tag and record["release_commit"] == commit + and record["base_commit"] == policy.base_commit and record["source_pr"] == 1041, + "Admission does not match this exact release") + require(git_text("rev-parse", "refs/tags/v0.74.7^{commit}") == policy.base_commit, + "Maintenance base tag mismatch") + + source = merged_pr(api, 1041, "maintenance/v0.74") + require(source.get("merge_commit_sha") == commit + and source.get("head", {}).get("sha") == record["source_commit"], + "Maintenance merge or reviewed source mismatch") + upstream = merged_pr(api, record["upstream_pr"], "main") + require(upstream.get("merge_commit_sha") == record["upstream_commit"], + "Upstream merge mismatch") + require(ancestor(record["upstream_commit"], "refs/remotes/origin/main"), + "JWT fix is not on protected main") + require(patch_id(record["upstream_commit"] + "^", record["upstream_commit"]) + == policy.auth_patch_id, "Upstream JWT patch mismatch") + + git("fetch", "--no-tags", "origin", "refs/heads/maintenance/v0.74:refs/remotes/origin/maintenance/v0.74", + "refs/pull/1041/head:refs/release-source/1041") + require(git_text("rev-parse", "refs/release-source/1041") == record["source_commit"], + "Reviewed maintenance source moved") + require(ancestor(commit, "refs/remotes/origin/maintenance/v0.74") + and ancestor(policy.base_commit, commit) + and ancestor(policy.base_commit, record["source_commit"]), "Maintenance lineage mismatch") + require(git_text("rev-parse", f"{commit}^{{tree}}") == record["release_tree"] + and git_text("rev-parse", f"{record['source_commit']}^{{tree}}") == record["release_tree"], + "Release tree differs from reviewed source") + require(patch_id(policy.base_commit, record["source_commit"]) == policy.auth_patch_id, + "Backported JWT patch mismatch") + run = api(f"actions/runs/{record['source_ci_run']}") + require(type(run) is dict and run.get("id") == record["source_ci_run"] + and run.get("repository", {}).get("full_name") == REPOSITORY + and run.get("path") == ".github/workflows/maintenance-jwt.yml" + and run.get("head_sha") == record["source_commit"] + and run.get("event") == "pull_request" + and run.get("status") == "completed" and run.get("conclusion") == "success" + and any(pr.get("number") == 1041 for pr in run.get("pull_requests", [])), + "Exact maintenance source CI is not successful") + return "reviewed-v0.74.8" + + +if __name__ == "__main__": + try: + print("Release provenance verified: " + verify_release(os.environ)) + except (Denied, UnicodeError, KeyError, TypeError, AttributeError) as error: + # Never print remote responses, commit messages, or API stderr. + print("Release provenance denied: " + (str(error) if isinstance(error, Denied) else "Invalid provenance data")) + raise SystemExit(1)