the-custodian/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r2/apps-release-build-source.patch
codex 109a90a424
Some checks are pending
CI Smoke / host-smoke (push) Waiting to run
CI Smoke / container-smoke (push) Waiting to run
Reject generated-only commits / generated-commit-guard (push) Waiting to run
docs: preserve reviewed NetKingdom session artifacts
2026-11-01 23:20:36 +01:00

908 lines
52 KiB
Diff

--- a/Makefile
+++ b/Makefile
@@ -655,3 +655,30 @@
.PHONY: check-tools check-sops k8s-server-dry-run apps-pg-status apps-pg-restore-drill check-railiance01-kubeconfig check-inter-hub-image-tag check-inter-hub-image check-vergabe-image-tag check-vergabe-image vergabe-dry-run vergabe-deploy vergabe-ingress-deploy vergabe-status vergabe-migrate vergabe-seed vergabe-superuser vergabe-logs vergabe-db-url-secret check-coulomb-social-image-tag coulomb-social-dry-run coulomb-social-deploy coulomb-social-ingress-deploy coulomb-social-status coulomb-social-logs coulomb-social-env-secret coulomb-social-env-secret-dry-run eso-deploy forgejo-openbao-eso-token-apply forgejo-openbao-store-deploy forgejo-mailer-es-deploy forgejo-mailer-es-status forgejo-dry-run forgejo-server-dry-run forgejo-deploy forgejo-ingress-deploy forgejo-ssh-nodeport-deploy forgejo-status forgejo-smoke forgejo-npm-smoke forgejo-verify forgejo-operator-bootstrap forgejo-runner-registration-sops-bootstrap forgejo-secrets-check forgejo-logs forgejo-runner-registration-deploy forgejo-runner-deploy forgejo-runner-status forgejo-runner-logs inter-hub-render-baseline inter-hub-dry-run inter-hub-server-dry-run inter-hub-deploy inter-hub-status inter-hub-release-info inter-hub-smoke inter-hub-logs reuse-dry-run reuse-deploy reuse-status reuse-smoke reuse-logs reuse-forgejo-webhook reuse-openbao-eso-token-apply reuse-openbao-store-deploy reuse-runtime-es-deploy reuse-runtime-es-status reuse-webhook-smoke policy-nexus-check-binding policy-nexus-check-image policy-nexus-render policy-nexus-server-dry-run policy-nexus-deploy policy-nexus-status policy-nexus-smoke policy-nexus-rollback help
.PHONY: forgejo-secret-scan-template-deploy
+
+
+##@ Isolated Forgejo source release preparation (no deployment/publication)
+FORGEJO_BUILD_WORKSPACE ?= /tmp/railiance-apps-forgejo-release
+FORGEJO_SOURCE_ARCHIVE ?=
+FORGEJO_GO_ARCHIVE ?=
+FORGEJO_NODE_ARCHIVE ?=
+FORGEJO_BUILD_GOMODCACHE ?=
+FORGEJO_BUILD_GOCACHE_SEED ?=
+FORGEJO_BUILD_NPM_CACHE_SEED ?=
+
+.PHONY: forgejo-release-prepare forgejo-release-acquire-npm forgejo-release-build forgejo-release-verify forgejo-release-container
+forgejo-release-prepare: ## Verify archives/patch/locks and prepare a new isolated workspace
+ /usr/bin/python3 -I tools/forgejo-release-build.py prepare --workspace "$(FORGEJO_BUILD_WORKSPACE)" --source-archive "$(FORGEJO_SOURCE_ARCHIVE)" --go-archive "$(FORGEJO_GO_ARCHIVE)" --node-archive "$(FORGEJO_NODE_ARCHIVE)" --module-cache "$(FORGEJO_BUILD_GOMODCACHE)" $(if $(FORGEJO_BUILD_GOCACHE_SEED),--go-cache-seed "$(FORGEJO_BUILD_GOCACHE_SEED)") $(if $(FORGEJO_BUILD_NPM_CACHE_SEED),--npm-cache-seed "$(FORGEJO_BUILD_NPM_CACHE_SEED)")
+
+forgejo-release-acquire-npm: ## Explicit public registry dependency acquisition; ignores lifecycle scripts
+ /usr/bin/python3 -I tools/forgejo-release-build.py acquire-npm --workspace "$(FORGEJO_BUILD_WORKSPACE)"
+
+forgejo-release-build: ## Offline full frontend, bindata and static backend via upstream targets
+ /usr/bin/python3 -I tools/forgejo-release-build.py build --workspace "$(FORGEJO_BUILD_WORKSPACE)"
+
+forgejo-release-verify: ## Verify all embedded assets, static executable and new CLI shape offline
+ /usr/bin/python3 -I tools/forgejo-release-build.py verify --workspace "$(FORGEJO_BUILD_WORKSPACE)"
+
+forgejo-release-container: ## Build/verify a deterministic local rootless OCI archive; never publish or deploy
+ /usr/bin/python3 -I tools/forgejo-release-build.py container --workspace "$(FORGEJO_BUILD_WORKSPACE)"
+
--- /dev/null
+++ b/build/forgejo/README.md
@@ -0,0 +1,72 @@
+# Forgejo release preparation (build only)
+
+The Apps Makefile targets package the independently reviewed seven-path Forgejo
+11.0.3 source extension. They never deploy, push, change default values or choose
+an operational image. Third-party application source is expanded only into a new
+`/tmp` workspace; this repository retains a release input lock and exact reviewed
+patch, not a second source-code tree. Upstream source ownership and publication
+remain separate owner work.
+
+Supply explicit public input archives/cache paths to `make forgejo-release-prepare`:
+`FORGEJO_SOURCE_ARCHIVE`, `FORGEJO_GO_ARCHIVE`, `FORGEJO_NODE_ARCHIVE`,
+`FORGEJO_BUILD_GOMODCACHE`, optionally `FORGEJO_BUILD_GOCACHE_SEED` and
+`FORGEJO_BUILD_NPM_CACHE_SEED`. Every archive, patch, source output, dependency
+lock and selected host compiler tool is digest-bound by `inputs.lock.json`.
+Go's official checksums and Node's official SHASUMS must be independently checked
+when acquiring those public archives. The source archive is official Forgejo
+v11.0.3; archive PAX commit b81aa4d0807cc1e2d6a2fbafd0b75c46cc0ae237.
+
+The workspace must not exist. Existing trials are preserved, never restarted
+because an observer timed out. The recipe's `process.json` records the exact
+active child PID, phase and command, then completion; logs and receipts stay in
+the workspace. Do not run two phases against the same workspace concurrently.
+
+`make forgejo-release-acquire-npm` explicitly acquires only public registry
+packages, with fresh HOME/config paths, integrity-bound lockfile and lifecycle
+scripts disabled. With the cache ready, `make forgejo-release-build` reruns
+`npm ci --offline --ignore-scripts`, Go module verification, upstream frontend
+compilation and upstream generation/static backend targets inside a network
+namespace without connectivity. It uses the exact Go/Node distributions, local
+compiler inputs, `bindata timetzdata sqlite sqlite_unlock_notify` plus upstream
+static target's `netgo osusergo`, explicit Make GOFLAGS for trimpath/buildvcs and
+fixed file/directory timestamps. Build tags retain the upstream rootless build
+profile. `make forgejo-release-verify` compares every embedded public/options/
+template file to actual generated source bytes, checks the extension CLI and
+checks the ELF has no dynamic interpreter. These are local artifact checks,
+not application login, DB, native rollout or architecture acceptance.
+
+For a **local container build** only, first acquire the locked public amd64
+base with an empty Docker client config and the explicit local socket. Then
+`make forgejo-release-container` uses that exact digest as a build input and a
+minimal context containing only the verified executable and generated Dockerfile.
+It directly exports a local OCI archive with `rewrite-timestamp=true`, no-cache,
+no image load or push. SOURCE_DATE_EPOCH alone does not fix COPY parent-directory
+timestamps. The image epoch is max(source epoch,ceil immutable base created
+instant), so the original base layers remain exact while new metadata is stable.
+The recipe verifies every OCI blob, original base layer prefix, rootless runtime
+configuration, provenance labels and exact final executable. Two no-cache exports
+must reproduce the frozen output image and archive digests before claiming
+reproducibility. The actual local proof receipts are in the accompanying source
+proposal packet, not an admission of the resulting artifact.
+
+There is no deployment tag, registry publication, RUN directive, privileged
+container or Docker host/secret mount. Public base acquisition and a standard
+local Docker/buildx daemon supporting OCI export/layer timestamp rewriting are
+explicit prerequisites. An unavailable feature is a retained blocker; do not
+switch to a privileged builder or claim a successful image from a backend-only
+binary. Existing contexts/output archives refuse overwrite. Reconcile an actual
+failed export explicitly, retaining original metadata/logs/artifacts; observation
+timeouts never justify another build. Older prototype load/save behavior and
+its config-versus-manifest-ID issue are retained in the source trial history,
+not shipped as a production continuation.
+
+The static host toolchain is pinned for this Linux amd64 review estate. Broader
+cross-host reproducibility or a different architecture/compiler/libc is a new
+input revision, not an implicit fallback. The glibc static-linker's optional
+SQLite `dlopen` diagnostic is retained; no SQLite-extension runtime compatibility
+or production platform admission is inferred from ELF inspection or asset checks.
+An accepted owner image/source/backend, authenticated access-engine decision,
+private secret-input boundary, type/case/descriptor re-freeze, current operation/
+inverse sessions, durable journal and application/Helm writer exclusion are
+separate gates. Helm omission/rollback does not remove persisted OAuth source.
+The existing case gate is compatibility code, not a single-PDP exception.
--- /dev/null
+++ b/build/forgejo/inputs.lock.json
@@ -0,0 +1,63 @@
+{
+ "schema": "railiance-apps.forgejo-release-inputs.v1",
+ "forgejo_version": "11.0.3",
+ "source_archive_sha256": "1ddd8c926ca07d40436897971458ace7dcf1d3496a0813ab9e0d869edef2365c",
+ "source_commit": "b81aa4d0807cc1e2d6a2fbafd0b75c46cc0ae237",
+ "patch_sha256": "f2788b3f2bf84dbc0f94e232b6c9614a864b8123c09f9e44703d50d5e1b53257",
+ "go_archive_sha256": "3333f6ea53afa971e9078895eaa4ac7204a8c6b5c68c10e6bc9a33e8e391bdd8",
+ "go_version": "1.24.3",
+ "node_archive_sha256": "dafe2e8f82cb97de1bd10db9e2ec4c07bbf53389b0799b1e095a918951e78fd4",
+ "node_version": "22.15.0",
+ "source_date_epoch": 1752105237,
+ "release_version": "11.0.3+nk48.f2788b3f",
+ "tags": "bindata timetzdata sqlite sqlite_unlock_notify",
+ "runtime_base": "code.forgejo.org/forgejo/forgejo@sha256:41649dff5311664097d29b4d80620403f0b27e7c0eee0b8af79b132a37f3807b",
+ "runtime_index_digest": "sha256:691657c7e64af7730a2e885e6d022cf16ae60724931c5392bd61838afadcaeba",
+ "platform": "linux/amd64",
+ "reviewed_outputs": [
+ {
+ "path": "cmd/admin_auth_oauth.go",
+ "sha256": "1dd03f0bd23889a340a10fcc18d2e3a49270b79056aafd1b3baf01b18559b37f"
+ },
+ {
+ "path": "routers/web/admin/auths.go",
+ "sha256": "3498229e06fb9ff52396bcb5e68c0cc2d2ad24e6494533d8717d7c833f3e2923"
+ },
+ {
+ "path": "routers/web/auth/oauth.go",
+ "sha256": "081bcb94023d6e27289196bbe518bebba0a8a6082ea1a32d2794c5562075907b"
+ },
+ {
+ "path": "services/auth/source/oauth2/providers_openid.go",
+ "sha256": "5dd9b906cce434c774968d57fcc790cb678147345fea4d2e58477ec6eed70bcf"
+ },
+ {
+ "path": "services/auth/source/oauth2/required_claim_values.go",
+ "sha256": "29f16d07464bbe12e3f65616b6c1eb60561d04cd8ff89ecbfb56f93dbeaf1cda"
+ },
+ {
+ "path": "services/auth/source/oauth2/required_claim_values_test.go",
+ "sha256": "54a8bd97dc51c524a9adf0c6aefb9a19fa152d234b3bb929bf1a90932662d3d0"
+ },
+ {
+ "path": "services/auth/source/oauth2/source.go",
+ "sha256": "44e99883c2ec479427b3f72c73353fe33936eed2e5c962b2fa40803576aa5642"
+ }
+ ],
+ "lockfiles": {
+ "go.mod": "652162a4a95c6d1c349d2a8c0b514def5abd837f001137b77b7b3f4a0f162d00",
+ "go.sum": "19c305c7e68de9d3f1bc0dda2de09c5d38597f1ee7fa411c7b36b92467ae1eaf",
+ "package.json": "ab5c54ec7077501b50acf3ab5f1fff4f63a51abe99e720b8ecdc6cc6c78f39fc",
+ "package-lock.json": "fdd5c2f891311be7c01b1907b418c243ca1f0f951fd4b4ee5b5e931c43493657"
+ },
+ "host_tools": {
+ "/usr/bin/make": "d78b8f1d099fbcfb6f2f49ab87223b9b68fb3956642f92d6ec6de812e8afa965",
+ "/usr/bin/gcc": "1b99826121ae6682a634e5efe09bd3e3df58ce58e0b28f849114ab5b89139c26",
+ "/usr/bin/ld": "e46a122cc3a29feba3b1d0fc2a274b6e46538be016ebe381370c0801bb98b0b8",
+ "/usr/bin/ar": "534681ac11c18868cfc4fdf98770aa0ba8973eedc90c231e94e6ba96e1a04f27",
+ "/usr/bin/bwrap": "e318903862396f96de3df57264e0158682b952fd3fb53ac23d876413e7b30f71",
+ "/usr/lib/x86_64-linux-gnu/libc.a": "a40c7eae7581f977b65eddb81cbc799213a695749e039d966db55bdee4ac9621",
+ "/usr/lib/x86_64-linux-gnu/libm.a": "420cd3c406f6f8c12deb3d1291146e9c091ee015a7c39eaf2237f01e80d6f0ae"
+ },
+ "runtime_base_config_sha256": "bc5a3df111729582faadebd01d126ba769e2b65d0c62f306f6bc79f4e562452b"
+}
--- /dev/null
+++ b/build/forgejo/required-claim-values.patch
@@ -0,0 +1,484 @@
+--- a/cmd/admin_auth_oauth.go
++++ b/cmd/admin_auth_oauth.go
+@@ -94,6 +94,10 @@
+ Name: "required-claim-value",
+ Value: "",
+ Usage: "Claim value that has to be set to allow users to login with this source",
++ },
++ &cli.StringSliceFlag{
++ Name: "required-claim-values",
++ Usage: "Require any one exact array value from current OpenID Connect UserInfo (cannot combine with required-claim-value)",
+ },
+ &cli.StringFlag{
+ Name: "group-claim-name",
+@@ -160,6 +164,7 @@
+ Scopes: c.StringSlice("scopes"),
+ RequiredClaimName: c.String("required-claim-name"),
+ RequiredClaimValue: c.String("required-claim-value"),
++ RequiredClaimValues: c.StringSlice("required-claim-values"),
+ GroupClaimName: c.String("group-claim-name"),
+ AdminGroup: c.String("admin-group"),
+ RestrictedGroup: c.String("restricted-group"),
+@@ -177,6 +182,12 @@
+ }
+
+ config := parseOAuth2Config(c)
++ if err := oauth2.ValidateRequiredClaimValues(config.RequiredClaimName, config.RequiredClaimValue, config.RequiredClaimValues); err != nil {
++ return err
++ }
++ if config.RequiredClaimValues != nil && config.Provider != "openidConnect" {
++ return errors.New("required-claim-values requires openidConnect")
++ }
+ if config.Provider == "openidConnect" {
+ discoveryURL, err := url.Parse(config.OpenIDConnectAutoDiscoveryURL)
+ if err != nil || (discoveryURL.Scheme != "http" && discoveryURL.Scheme != "https") {
+@@ -246,6 +257,10 @@
+ oAuth2Config.RequiredClaimValue = c.String("required-claim-value")
+ }
+
++ if c.IsSet("required-claim-values") {
++ oAuth2Config.RequiredClaimValues = c.StringSlice("required-claim-values")
++ }
++
+ if c.IsSet("group-claim-name") {
+ oAuth2Config.GroupClaimName = c.String("group-claim-name")
+ }
+@@ -293,6 +308,12 @@
+ }
+
+ oAuth2Config.CustomURLMapping = customURLMapping
++ if err := oauth2.ValidateRequiredClaimValues(oAuth2Config.RequiredClaimName, oAuth2Config.RequiredClaimValue, oAuth2Config.RequiredClaimValues); err != nil {
++ return err
++ }
++ if oAuth2Config.RequiredClaimValues != nil && oAuth2Config.Provider != "openidConnect" {
++ return errors.New("required-claim-values requires openidConnect")
++ }
+ source.Cfg = oAuth2Config
+
+ return auth_model.UpdateSource(ctx, source)
+--- a/routers/web/admin/auths.go
++++ b/routers/web/admin/auths.go
+@@ -322,6 +322,13 @@
+ ctx.Data["Source"] = source
+ ctx.Data["HasTLS"] = source.HasTLS()
+
++ // The legacy form has no representation for the opt-in current-UserInfo gate.
++ // Refuse rather than silently dropping it during source reconstruction.
++ if current, ok := source.Cfg.(*oauth2.Source); ok && current.RequiredClaimValues != nil {
++ ctx.RenderWithErr("This OAuth source uses required-claim-values; update it through the reviewed CLI.", tplAuthEdit, form)
++ return
++ }
++
+ if ctx.HasError() {
+ ctx.HTML(http.StatusOK, tplAuthEdit)
+ return
+--- a/routers/web/auth/oauth.go
++++ b/routers/web/auth/oauth.go
+@@ -1427,7 +1427,11 @@
+ return goth.User{}, err
+ }
+
+- if oauth2Source.RequiredClaimName != "" {
++ if oauth2Source.RequiredClaimValues != nil {
++ if oauth2Source.Provider != "openidConnect" || !oauth2.MatchRequiredClaimValues(gothUser.RawData, oauth2Source.RequiredClaimName, oauth2Source.RequiredClaimValue, oauth2Source.RequiredClaimValues) {
++ return goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID}
++ }
++ } else if oauth2Source.RequiredClaimName != "" {
+ claimInterface, has := gothUser.RawData[oauth2Source.RequiredClaimName]
+ if !has {
+ return goth.User{}, user_model.ErrUserProhibitLogin{Name: gothUser.UserID}
+--- a/services/auth/source/oauth2/providers_openid.go
++++ b/services/auth/source/oauth2/providers_openid.go
+@@ -4,6 +4,7 @@
+ package oauth2
+
+ import (
++ "errors"
+ "html/template"
+
+ "forgejo.org/modules/log"
+@@ -43,6 +44,22 @@
+ if err != nil {
+ log.Warn("Failed to create OpenID Connect Provider with name '%s' with url '%s': %v", providerName, source.OpenIDConnectAutoDiscoveryURL, err)
+ }
++ if err != nil {
++ return nil, err
++ }
++ if source.RequiredClaimValues != nil {
++ if err := ValidateRequiredClaimValues(source.RequiredClaimName, source.RequiredClaimValue, source.RequiredClaimValues); err != nil {
++ return nil, err
++ }
++ if provider.OpenIDConfig == nil || provider.SkipUserInfoRequest {
++ return nil, errors.New("required-claim-values needs current UserInfo")
++ }
++ guarded, err := RequireCurrentUserInfoClaim(provider.Client(), provider.OpenIDConfig.UserInfoEndpoint, source.RequiredClaimName)
++ if err != nil {
++ return nil, err
++ }
++ provider.HTTPClient = guarded
++ }
+ return provider, err
+ }
+
+--- /dev/null
++++ b/services/auth/source/oauth2/required_claim_values.go
+@@ -0,0 +1,202 @@
++// Copyright 2026 The Forgejo Authors. All rights reserved.
++// SPDX-License-Identifier: MIT
++
++package oauth2
++
++import (
++ "bytes"
++ "encoding/json"
++ "errors"
++ "io"
++ "net/http"
++ "net/url"
++ "strings"
++ "unicode"
++ "unicode/utf8"
++)
++
++const maxClaimValues = 1024
++const maxUserInfoBytes = 1 << 20
++
++// ValidateRequiredClaimValues validates only the opt-in list extension. A nil
++// list retains every legacy single-value behavior. An explicitly empty list
++// fails closed rather than turning a configured gate into claim-presence-only.
++func ValidateRequiredClaimValues(name, legacy string, values []string) error {
++ if values == nil {
++ return nil
++ }
++ if !validClaimLiteral(name) || legacy != "" || len(values) == 0 || len(values) > 64 {
++ return errors.New("invalid or conflicting required-claim-values configuration")
++ }
++ seen := map[string]bool{}
++ for _, value := range values {
++ if !validClaimLiteral(value) || seen[value] {
++ return errors.New("invalid required-claim-values literal")
++ }
++ seen[value] = true
++ }
++ return nil
++}
++
++func validClaimLiteral(value string) bool {
++ return value != "" && len(value) <= 256 && utf8.ValidString(value) &&
++ value == strings.TrimSpace(value) && !strings.ContainsAny(value, "\x00\r\n,") &&
++ !strings.ContainsFunc(value, func(r rune) bool { return unicode.IsControl(r) || r == utf8.RuneError })
++}
++
++// strictClaimValues accepts only a well-formed claim array. No scalar parsing,
++// coercion, regex or prefix matching is introduced into the new gate.
++func strictClaimValues(raw any) ([]string, bool) {
++ var values []string
++ switch v := raw.(type) {
++ case []string:
++ values = v
++ case []any:
++ values = make([]string, len(v))
++ for i, value := range v {
++ s, ok := value.(string)
++ if !ok {
++ return nil, false
++ }
++ values[i] = s
++ }
++ default:
++ return nil, false
++ }
++ if len(values) > maxClaimValues {
++ return nil, false
++ }
++ for _, value := range values {
++ if !validClaimLiteral(value) {
++ return nil, false
++ }
++ }
++ return values, true
++}
++
++// MatchRequiredClaimValues evaluates the new consumer gate against current
++// authenticated provider data; it does not mutate or manufacture issuer claims.
++func MatchRequiredClaimValues(claims map[string]any, name, legacy string, required []string) bool {
++ if required == nil || ValidateRequiredClaimValues(name, legacy, required) != nil {
++ return false
++ }
++ raw, present := claims[name]
++ if !present {
++ return false
++ }
++ values, ok := strictClaimValues(raw)
++ if !ok {
++ return false
++ }
++ for _, value := range values {
++ for _, wanted := range required {
++ if value == wanted {
++ return true
++ }
++ }
++ }
++ return false
++}
++
++// RequireCurrentUserInfoClaim preserves the existing HTTP client settings and
++// adds a strict response guard for this opt-in source only. A current UserInfo
++// array must overwrite any stale ID-token claim. Missing/invalid data and HTTP
++// failures cannot fall back to the ID-token map in Goth's merging code.
++func RequireCurrentUserInfoClaim(client *http.Client, endpoint, claim string) (*http.Client, error) {
++ u, err := url.Parse(endpoint)
++ if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.Fragment != "" || !validClaimLiteral(claim) {
++ return nil, errors.New("required-claim-values needs an HTTPS UserInfo endpoint")
++ }
++ if client == nil {
++ client = http.DefaultClient
++ }
++ result := *client
++ base := result.Transport
++ if base == nil {
++ base = http.DefaultTransport
++ }
++ result.Transport = &currentClaimTransport{base: base, endpoint: endpoint, claim: claim}
++ // Redirects could evade endpoint matching or send a bearer to another origin.
++ // They are unsupported for the new guarded provider; legacy clients are intact.
++ result.CheckRedirect = func(*http.Request, []*http.Request) error {
++ return errors.New("guarded OAuth redirects are not supported")
++ }
++ return &result, nil
++}
++
++type currentClaimTransport struct {
++ base http.RoundTripper
++ endpoint, claim string
++}
++
++func (t *currentClaimTransport) RoundTrip(req *http.Request) (*http.Response, error) {
++ response, err := t.base.RoundTrip(req)
++ if req.URL.String() != t.endpoint {
++ return response, err
++ }
++ if err != nil {
++ if response != nil && response.Body != nil {
++ response.Body.Close()
++ }
++ return nil, errors.New("current UserInfo lookup failed")
++ }
++ if response == nil || response.Body == nil {
++ return nil, errors.New("current UserInfo response missing")
++ }
++ defer response.Body.Close()
++ if req.Method != http.MethodGet || response.StatusCode != http.StatusOK {
++ return nil, errors.New("current UserInfo refused")
++ }
++ body, err := io.ReadAll(io.LimitReader(response.Body, maxUserInfoBytes+1))
++ if err != nil || len(body) > maxUserInfoBytes || !utf8.Valid(body) {
++ return nil, errors.New("current UserInfo response invalid")
++ }
++ // Reject duplicate top-level keys before Unmarshal can hide a conflicting
++ // groups or subject claim. json.RawMessage permits unrelated normal claims.
++ decoder := json.NewDecoder(bytes.NewReader(body))
++ token, err := decoder.Token()
++ if err != nil || token != json.Delim('{') {
++ return nil, errors.New("current UserInfo object required")
++ }
++ claims := map[string]json.RawMessage{}
++ for decoder.More() {
++ keyToken, err := decoder.Token()
++ if err != nil {
++ return nil, errors.New("current UserInfo key invalid")
++ }
++ key, ok := keyToken.(string)
++ if !ok {
++ return nil, errors.New("current UserInfo key invalid")
++ }
++ if _, duplicate := claims[key]; duplicate {
++ return nil, errors.New("current UserInfo duplicate claim")
++ }
++ var value json.RawMessage
++ if decoder.Decode(&value) != nil {
++ return nil, errors.New("current UserInfo value invalid")
++ }
++ claims[key] = value
++ }
++ if token, err = decoder.Token(); err != nil || token != json.Delim('}') {
++ return nil, errors.New("current UserInfo object invalid")
++ }
++ var trailing any
++ if decoder.Decode(&trailing) != io.EOF {
++ return nil, errors.New("current UserInfo trailing data")
++ }
++ raw, present := claims[t.claim]
++ if !present {
++ return nil, errors.New("current UserInfo required claim missing")
++ }
++ var value any
++ if json.Unmarshal(raw, &value) != nil {
++ return nil, errors.New("current UserInfo required claim invalid")
++ }
++ if _, ok := strictClaimValues(value); !ok {
++ return nil, errors.New("current UserInfo required claim array invalid")
++ }
++ // Return the original public response bytes to Goth. Subject equality and
++ // audience/issuer checks remain in its existing authenticated provider path.
++ response.Body = io.NopCloser(bytes.NewReader(body))
++ return response, nil
++}
+--- /dev/null
++++ b/services/auth/source/oauth2/required_claim_values_test.go
+@@ -0,0 +1,144 @@
++// Copyright 2026 The Forgejo Authors. All rights reserved.
++// SPDX-License-Identifier: MIT
++package oauth2
++
++import (
++ "errors"
++ "io"
++ "net/http"
++ "reflect"
++ "strings"
++ "testing"
++)
++
++var caseValues = []string{"forge-admins", "forge-coulomb-owners", "forge-coulomb-readers"}
++
++type roundTripFunc func(*http.Request) (*http.Response, error)
++
++func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
++
++func mockUserInfo(body string, status int) *http.Client {
++ return &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
++ return &http.Response{StatusCode: status, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(body)), Request: r}, nil
++ })}
++}
++
++func TestRequiredClaimExactAnyOfAndNoMutation(t *testing.T) {
++ for _, group := range caseValues {
++ groups := []any{group, "unrelated"}
++ claims := map[string]any{"groups": groups, "other": "preserved"}
++ if !MatchRequiredClaimValues(claims, "groups", "", caseValues) {
++ t.Fatal("individual allowed group rejected")
++ }
++ if !reflect.DeepEqual(claims, map[string]any{"groups": groups, "other": "preserved"}) {
++ t.Fatal("claims mutated")
++ }
++ }
++ if !MatchRequiredClaimValues(map[string]any{"groups": append([]string(nil), caseValues...)}, "groups", "", caseValues) {
++ t.Fatal("multiple allowed groups rejected")
++ }
++ for _, raw := range []any{nil, true, 1, "forge-admins", "forge-admins,forge-coulomb-owners", []any{"forge-admins", 1}, []any{"forge-admins", nil}, []string{}, []any{}, []string{"forge-admins-lookalike"}, []string{" Forge-admins"}, []string{"forge-admins\n"}, []string{"Forge-admins"}, []string{"forge-admins,other"}, []string{"forge-admins", ""}} {
++ if MatchRequiredClaimValues(map[string]any{"groups": raw}, "groups", "", caseValues) {
++ t.Fatal("malformed/nonmatching claim accepted")
++ }
++ }
++ if MatchRequiredClaimValues(map[string]any{}, "groups", "", caseValues) {
++ t.Fatal("absent claim accepted")
++ }
++ if MatchRequiredClaimValues(map[string]any{"unrelated": []string{"forge-admins"}}, "groups", "", caseValues) {
++ t.Fatal("unrelated claim used")
++ }
++}
++
++func TestRequiredClaimConfigurationRefusalsAndLegacyIsolation(t *testing.T) {
++ if ValidateRequiredClaimValues("", "old arbitrary literal", nil) != nil {
++ t.Fatal("legacy config changed")
++ }
++ for _, cfg := range []struct {
++ name, legacy string
++ values []string
++ }{
++ {"groups", "", []string{}}, {"groups", "", []string{""}}, {"groups", "old", caseValues}, {"", "", caseValues},
++ {"groups", "", []string{"a", "a"}}, {"groups", "", []string{"a,b"}}, {"groups", "", []string{" a"}},
++ } {
++ if ValidateRequiredClaimValues(cfg.name, cfg.legacy, cfg.values) == nil {
++ t.Fatal("invalid config accepted")
++ }
++ }
++ if ValidateRequiredClaimValues("groups", "", caseValues) != nil {
++ t.Fatal("case config invalid")
++ }
++}
++
++func TestCurrentUserInfoGuardRejectsMissingMalformedAndUnavailable(t *testing.T) {
++ for _, body := range []string{
++ `{}`, `{"sub":"alice"}`, `{"groups":null}`, `{"groups":true}`, `{"groups":"forge-admins"}`,
++ `{"groups":["forge-admins",null]}`, `{"groups":["forge-admins",1]}`,
++ `{"groups":["forge-admins"],"groups":[]}`, `{"groups":[]} {}`, `[]`, `{"groups":[]`,
++ `{"groups":["forge-admins",""]}`, strings.Repeat(" ", maxUserInfoBytes+1),
++ } {
++ client, err := RequireCurrentUserInfoClaim(mockUserInfo(body, 200), "https://issuer.example/userinfo", "groups")
++ if err != nil {
++ t.Fatal(err)
++ }
++ response, err := client.Get("https://issuer.example/userinfo")
++ if err == nil {
++ response.Body.Close()
++ t.Fatal("invalid current claim did not fail closed")
++ }
++ }
++ client, _ := RequireCurrentUserInfoClaim(mockUserInfo(`{"groups":[]}`, 503), "https://issuer.example/userinfo", "groups")
++ if response, err := client.Get("https://issuer.example/userinfo"); err == nil {
++ response.Body.Close()
++ t.Fatal("non200 accepted")
++ }
++ broken := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { return nil, errors.New("private backend error") })}
++ client, _ = RequireCurrentUserInfoClaim(broken, "https://issuer.example/userinfo", "groups")
++ if _, err := client.Get("https://issuer.example/userinfo"); err == nil || strings.Contains(err.Error(), "private backend") {
++ t.Fatal("lookup failure not contained")
++ }
++}
++
++func TestCurrentUserInfoEmptyArrayOverwritesAndGroupRemoval(t *testing.T) {
++ for _, body := range []string{`{"sub":"alice","groups":[]}`, `{"sub":"alice","groups":["forge-coulomb-readers"]}`} {
++ client, err := RequireCurrentUserInfoClaim(mockUserInfo(body, 200), "https://issuer.example/userinfo", "groups")
++ if err != nil {
++ t.Fatal(err)
++ }
++ response, err := client.Get("https://issuer.example/userinfo")
++ if err != nil {
++ t.Fatal(err)
++ }
++ b, _ := io.ReadAll(response.Body)
++ response.Body.Close()
++ if string(b) != body {
++ t.Fatal("response changed")
++ }
++ }
++}
++
++func TestCurrentUserInfoGuardIsOptInAndRedirectsRefuse(t *testing.T) {
++ original := mockUserInfo(`{"unrelated":true}`, 200)
++ guarded, err := RequireCurrentUserInfoClaim(original, "https://issuer.example/userinfo", "groups")
++ if err != nil {
++ t.Fatal(err)
++ }
++ response, err := original.Get("https://issuer.example/userinfo")
++ if err != nil {
++ t.Fatal("original client mutated")
++ }
++ response.Body.Close()
++ response, err = guarded.Get("https://issuer.example/unrelated")
++ if err != nil {
++ t.Fatal("unrelated endpoint changed")
++ }
++ response.Body.Close()
++ if guarded.CheckRedirect == nil || guarded.CheckRedirect(nil, nil) == nil {
++ t.Fatal("redirect can bypass guard")
++ }
++ for _, endpoint := range []string{"", "http://issuer.example/userinfo", "https://user:pass@issuer.example/userinfo", "https://issuer.example/userinfo#fragment"} {
++ if _, err := RequireCurrentUserInfoClaim(original, endpoint, "groups"); err == nil {
++ t.Fatal("invalid endpoint accepted")
++ }
++ }
++}
+--- a/services/auth/source/oauth2/source.go
++++ b/services/auth/source/oauth2/source.go
+@@ -22,6 +22,7 @@
+ Scopes []string
+ AttributeSSHPublicKey string
+ RequiredClaimName string
++ RequiredClaimValues []string `json:",omitempty"`
+ RequiredClaimValue string
+ GroupClaimName string
+ AdminGroup string
--- /dev/null
+++ b/build/forgejo/runtime-base-config.json
@@ -0,0 +1 @@
+{"architecture":"amd64","config":{"User":"1000:1000","ExposedPorts":{"2222/tcp":{},"3000/tcp":{}},"Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin","GITEA_WORK_DIR=/var/lib/gitea","GITEA_CUSTOM=/var/lib/gitea/custom","GITEA_TEMP=/tmp/gitea","TMPDIR=/tmp/gitea","GITEA_APP_INI_LEGACY=/etc/gitea/app.ini","GITEA_APP_INI=/var/lib/gitea/custom/conf/app.ini","HOME=/var/lib/gitea/git"],"Entrypoint":["/usr/bin/dumb-init","--","/usr/local/bin/docker-entrypoint.sh"],"Volumes":{"/etc/gitea":{},"/var/lib/gitea":{}},"WorkingDir":"/var/lib/gitea","Labels":{"maintainer":"contact@forgejo.org","org.opencontainers.image.authors":"Forgejo","org.opencontainers.image.description":"Forgejo is a self-hosted lightweight software forge. Easy to install and low maintenance, it just does the job.","org.opencontainers.image.documentation":"https://forgejo.org/download/#container-image","org.opencontainers.image.licenses":"GPL-3.0-or-later","org.opencontainers.image.source":"https://codeberg.org/forgejo/forgejo","org.opencontainers.image.title":"Forgejo. Beyond coding. We forge.","org.opencontainers.image.url":"https://forgejo.org","org.opencontainers.image.vendor":"Forgejo","org.opencontainers.image.version":"11.0.3"},"ArgsEscaped":true},"created":"2025-07-10T04:12:21.034425519Z","history":[{"created":"2025-02-14T03:28:36Z","created_by":"ADD alpine-minirootfs-3.21.3-x86_64.tar.gz / # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-02-14T03:28:36Z","created_by":"CMD [\"/bin/sh\"]","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:08:22.463872647Z","created_by":"ARG RELEASE_VERSION=11.0.3","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:08:22.463872647Z","created_by":"LABEL maintainer=contact@forgejo.org org.opencontainers.image.authors=Forgejo org.opencontainers.image.url=https://forgejo.org org.opencontainers.image.documentation=https://forgejo.org/download/#container-image org.opencontainers.image.source=https://codeberg.org/forgejo/forgejo org.opencontainers.image.version=11.0.3 org.opencontainers.image.vendor=Forgejo org.opencontainers.image.licenses=GPL-3.0-or-later org.opencontainers.image.title=Forgejo. Beyond coding. We forge. org.opencontainers.image.description=Forgejo is a self-hosted lightweight software forge. Easy to install and low maintenance, it just does the job.","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:08:22.463872647Z","created_by":"EXPOSE map[2222/tcp:{} 3000/tcp:{}]","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:08:22.463872647Z","created_by":"RUN |1 RELEASE_VERSION=11.0.3 /bin/sh -c apk --no-cache add bash ca-certificates dumb-init gettext git curl gnupg openssh-client \u0026\u0026 rm -rf /var/cache/apk/* # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:08:22.524887902Z","created_by":"RUN |1 RELEASE_VERSION=11.0.3 /bin/sh -c addgroup -S -g 1000 git \u0026\u0026 adduser -S -H -D -h /var/lib/gitea/git -s /bin/bash -u 1000 -G git git # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:08:22.622516492Z","created_by":"RUN |1 RELEASE_VERSION=11.0.3 /bin/sh -c mkdir -p /var/lib/gitea /etc/gitea # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:08:22.719074007Z","created_by":"RUN |1 RELEASE_VERSION=11.0.3 /bin/sh -c chown git:git /var/lib/gitea /etc/gitea # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:12:20.677652029Z","created_by":"COPY /tmp/local / # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:12:20.772570625Z","created_by":"RUN |1 RELEASE_VERSION=11.0.3 /bin/sh -c cd /usr/local/bin ; ln -s gitea forgejo # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:12:20.904591549Z","created_by":"COPY --chown=root:root /go/src/forgejo.org/gitea /app/gitea/gitea # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:12:20.992814487Z","created_by":"RUN |1 RELEASE_VERSION=11.0.3 /bin/sh -c ln -s /app/gitea/gitea /app/gitea/forgejo-cli # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:12:21.019203454Z","created_by":"COPY --chown=root:root /go/src/forgejo.org/environment-to-ini /usr/local/bin/environment-to-ini # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:12:21.027356213Z","created_by":"COPY /go/src/forgejo.org/contrib/autocompletion/bash_autocomplete /etc/profile.d/gitea_bash_autocomplete.sh # buildkit","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:12:21.027356213Z","created_by":"USER 1000:1000","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:12:21.027356213Z","created_by":"ENV GITEA_WORK_DIR=/var/lib/gitea","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:12:21.027356213Z","created_by":"ENV GITEA_CUSTOM=/var/lib/gitea/custom","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:12:21.027356213Z","created_by":"ENV GITEA_TEMP=/tmp/gitea","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:12:21.027356213Z","created_by":"ENV TMPDIR=/tmp/gitea","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:12:21.027356213Z","created_by":"ENV GITEA_APP_INI_LEGACY=/etc/gitea/app.ini","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:12:21.027356213Z","created_by":"ENV GITEA_APP_INI=/var/lib/gitea/custom/conf/app.ini","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:12:21.027356213Z","created_by":"ENV HOME=/var/lib/gitea/git","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:12:21.027356213Z","created_by":"VOLUME [/var/lib/gitea /etc/gitea]","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:12:21.034425519Z","created_by":"WORKDIR /var/lib/gitea","comment":"buildkit.dockerfile.v0"},{"created":"2025-07-10T04:12:21.034425519Z","created_by":"ENTRYPOINT [\"/usr/bin/dumb-init\" \"--\" \"/usr/local/bin/docker-entrypoint.sh\"]","comment":"buildkit.dockerfile.v0","empty_layer":true},{"created":"2025-07-10T04:12:21.034425519Z","created_by":"CMD []","comment":"buildkit.dockerfile.v0","empty_layer":true}],"os":"linux","rootfs":{"type":"layers","diff_ids":["sha256:08000c18d16dadf9553d747a58cf44023423a9ab010aab96cf263d2216b8b350","sha256:cf760eaa10b0319495870aab29b322acf75ba0755f51f1f1a23bcf636243025b","sha256:c6d4eb3ac8d434116be911d6c5dbfbbf600b5f8db34d8564f42f916516bc8bee","sha256:4bbca535f88127e38de4d207fc606ff1f09612b9b45f4a583b39781d2a45ff91","sha256:4d7e7dd4baa79b36260ede93d34c7d57e7370d714e129c374aec60371f751977","sha256:b422e6485954feb40fe6d57beb145775bca592f0fe068477845a7e843e8d8214","sha256:51a112d24ca275b0dd383e1a9c1f725c0967f56aff9ba393d87db1c208369873","sha256:3129f4769ae273912802802ef504d87add6e9181916fb9e74ee609375c474789","sha256:cc72664d7cdf5bcebdac72c859627611fd05733067b566481d180a398b30d16b","sha256:fa9096e760abba41eb596e3d47f4bb9e9a032e906deb4ffccafa07aa6619ca7b","sha256:2c5411c383707f038d6f60e17502f73fb92254dce8d8b748f432287d12b9a633","sha256:5f70bf18a086007016e948b04aed3b82103a36bea41755b6cddfaf10ace3c6ef"]}}
\ No newline at end of file
--- /dev/null
+++ b/tools/forgejo-release-build.py
@@ -0,0 +1,239 @@
+#!/usr/bin/env python3
+"""Build-only public Forgejo owner recipe; no deployment or publication code."""
+import argparse
+import hashlib
+import json
+import os
+from pathlib import Path
+import shutil
+import subprocess
+import tarfile
+import time
+import math
+import datetime
+
+OWNER=Path(__file__).resolve().parents[1]
+INPUTS=OWNER/'build/forgejo'
+LOCK=json.loads((INPUTS/'inputs.lock.json').read_text())
+
+def digest(path):
+ h=hashlib.sha256()
+ with Path(path).open('rb') as f:
+ for block in iter(lambda:f.read(1024*1024),b''):h.update(block)
+ return h.hexdigest()
+
+def save(path,data):
+ Path(path).write_text(json.dumps(data,indent=2,sort_keys=True)+'\n')
+
+def check(path,expected):
+ if digest(path)!=expected:raise ValueError('frozen public input digest differs: '+str(path))
+
+def base_env(work):
+ return {'PATH':str(work/'node-v22.15.0-linux-x64/bin')+':'+str(work/'go/bin')+':/usr/bin:/bin',
+ 'HOME':str(work/'home'),'GOTOOLCHAIN':'local','GOPROXY':'off','GOSUMDB':'off','GOVCS':'*:off',
+ 'GOPATH':str(work/'gopath'),'GOMODCACHE':json.loads((work/'prepared.json').read_text())['module_cache'],
+ 'GOCACHE':str(work/'gocache'),'CGO_ENABLED':'1','GOMAXPROCS':'4',
+ 'GOFLAGS':'-trimpath -buildvcs=false -p=4','SOURCE_DATE_EPOCH':str(LOCK['source_date_epoch']),
+ 'TZ':'UTC','LC_ALL':'C','NODE_OPTIONS':'--max-old-space-size=4096',
+ 'GIT_CONFIG_NOSYSTEM':'1','GIT_CONFIG_GLOBAL':'/dev/null','GIT_TERMINAL_PROMPT':'0',
+ 'NPM_CONFIG_USERCONFIG':str(work/'empty.npmrc'),'NPM_CONFIG_GLOBALCONFIG':str(work/'empty-global.npmrc'),
+ 'NPM_CONFIG_CACHE':str(work/'npm-cache'),'NPM_CONFIG_REGISTRY':'https://registry.npmjs.org',
+ 'NPM_CONFIG_OFFLINE':'true','NPM_CONFIG_AUDIT':'false','NPM_CONFIG_FUND':'false',
+ 'NPM_CONFIG_UPDATE_NOTIFIER':'false','PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD':'1'}
+
+def sandbox(work,env):
+ cache=Path(env['GOMODCACHE'])
+ return ['bwrap','--unshare-net','--unshare-pid','--ro-bind','/usr','/usr','--ro-bind','/lib','/lib',
+ '--ro-bind','/lib64','/lib64','--symlink','usr/bin','/bin','--proc','/proc','--dev','/dev',
+ '--tmpfs','/tmp','--bind',str(work),str(work),'--ro-bind',str(cache),str(cache),
+ '--chdir',str(work/'source'),'--','env','-i']+[k+'='+v for k,v in env.items()]
+
+def run(work,name,args,env=None,offline=False,cwd=None):
+ actual=sandbox(work,env)+args if offline else args
+ start=time.time()
+ with (work/(name+'.log')).open('w') as out:
+ proc=subprocess.Popen(actual,cwd=cwd or work,env={"PATH":"/usr/bin:/bin","HOME":str(work/"home")} if offline else env,stdout=out,stderr=subprocess.STDOUT)
+ save(work/'process.json',{'phase':name,'pid':proc.pid,'driver_pid':os.getpid(),'argv':args,'state':'running'})
+ code=proc.wait()
+ result={'phase':name,'argv':args,'returncode':code,'seconds':round(time.time()-start,2)}
+ save(work/(name+'-result.json'),result);save(work/'process.json',dict(result,state='complete',pid=None,driver_pid=None))
+ print(json.dumps(result),flush=True)
+ if code:raise RuntimeError('phase failed; retained log: '+str(work/(name+'.log')))
+ return result
+
+def checked_inputs(work):
+ for n,h in LOCK['host_tools'].items():check(n,h)
+ for n,h in LOCK['lockfiles'].items():check(work/'source'/n,h)
+ for x in LOCK['reviewed_outputs']:check(work/'source'/x['path'],x['sha256'])
+
+def prepare(a,work):
+ if work.exists():raise ValueError('workspace must be new; existing trial is never restarted or overwritten')
+ for n,key in [('source_archive','source_archive_sha256'),('go_archive','go_archive_sha256'),('node_archive','node_archive_sha256')]:
+ if not getattr(a,n):raise ValueError('explicit '+n+' required')
+ check(getattr(a,n),LOCK[key])
+ if not a.module_cache:raise ValueError('explicit public module cache required')
+ cache=Path(a.module_cache).resolve();assert cache.is_dir()
+ check(INPUTS/'required-claim-values.patch',LOCK['patch_sha256'])
+ for n,h in LOCK['host_tools'].items():check(n,h)
+ work.mkdir(parents=True);(work/'home').mkdir()
+ with tarfile.open(a.source_archive) as t:
+ # The official archive is digest-bound before any member is extracted.
+ parent=work/'unpacked';parent.mkdir();t.extractall(parent,filter='data')
+ folders=list(parent.iterdir());assert len(folders)==1 and folders[0].is_dir();folders[0].rename(work/'source');parent.rmdir()
+ for n in ['go_archive','node_archive']:
+ with tarfile.open(getattr(a,n)) as t:t.extractall(work,filter='data')
+ for n in ['empty.npmrc','empty-global.npmrc']:(work/n).write_text('')
+ if a.go_cache_seed:shutil.copytree(Path(a.go_cache_seed).resolve(),work/'gocache')
+ if a.npm_cache_seed:shutil.copytree(Path(a.npm_cache_seed).resolve(),work/'npm-cache')
+ subprocess.run(['/usr/bin/git','apply','--check',str(INPUTS/'required-claim-values.patch')],cwd=work/'source',check=True,env={'PATH':'/usr/bin:/bin','HOME':str(work/'home'),'GIT_CONFIG_NOSYSTEM':'1','GIT_CONFIG_GLOBAL':'/dev/null'})
+ subprocess.run(['/usr/bin/git','apply',str(INPUTS/'required-claim-values.patch')],cwd=work/'source',check=True,env={'PATH':'/usr/bin:/bin','HOME':str(work/'home'),'GIT_CONFIG_NOSYSTEM':'1','GIT_CONFIG_GLOBAL':'/dev/null'})
+ save(work/'prepared.json',{'module_cache':str(cache),'lock_sha256':digest(INPUTS/'inputs.lock.json'),'public_archives_verified':True})
+ checked_inputs(work)
+ packages=json.loads((work/'source/package-lock.json').read_text())['packages']
+ for name,package in packages.items():
+ if not name:continue
+ if not package.get('resolved','').startswith('https://registry.npmjs.org/') or not package.get('integrity','').startswith('sha512-'):
+ raise ValueError('non-public/non-integrity-bound npm package: '+name)
+ print(json.dumps({'prepared':True,'lockfile_npm_packages':len(packages)-1}))
+
+def normalize(folder):
+ epoch=LOCK['source_date_epoch']
+ for root,dirs,files in os.walk(folder):
+ dirs[:]=[x for x in dirs if x not in ('node_modules','.git')]
+ for n in files:
+ p=Path(root)/n
+ if not p.is_symlink():os.utime(p,(epoch,epoch))
+ os.utime(root,(epoch,epoch))
+
+def build(work):
+ checked_inputs(work);env=base_env(work)
+ run(work,'verify-go-modules',['go','mod','verify'],env,True)
+ run(work,'npm-offline-ci',['npm','ci','--offline','--ignore-scripts','--no-audit','--no-fund'],env,True)
+ normalize(work/'source')
+ version=['GOFLAGS=-trimpath -buildvcs=false -p=4','RELEASE_VERSION='+LOCK['release_version'],'FORGEJO_VERSION='+LOCK['release_version'],'FORGEJO_VERSION_API='+LOCK['release_version']]
+ run(work,'frontend',['make']+version+['frontend'],env,True)
+ for n in ['public','options','templates','modules/migration/schemas']:normalize(work/'source'/n)
+ run(work,'embedded-and-static-backend',['make']+version+['TAGS='+LOCK['tags'],'go-check','generate-backend','static-executable'],env,True)
+ checked_inputs(work)
+ save(work/'build.json',{'binary_sha256':digest(work/'source/gitea'),'source_locks_preserved':True,'frontend_built':True,'tags':LOCK['tags'],'image_deployment_selected':False})
+
+def verify(work):
+ checked_inputs(work);binary=work/'source/gitea'
+ target=work/'embedded-extracted'
+ if target.exists():raise ValueError('retain prior verification; extraction target already exists')
+ target.mkdir()
+ # No source, asset tree, host /usr, libraries, configuration or HOME is
+ # exposed to the executable. A filesystem fallback cannot pass this proof.
+ bare=['bwrap','--unshare-net','--unshare-pid','--proc','/proc','--dev','/dev',
+ '--tmpfs','/tmp','--dir','/app','--ro-bind',str(binary),'/app/forgejo',
+ '--bind',str(target),'/results','--chdir','/tmp','--clearenv']
+ for k,v in {'PATH':'/nonexistent','HOME':'/tmp/home','USER':'builder','USERNAME':'builder',
+ 'GITEA_WORK_DIR':'/tmp/work','GITEA_CUSTOM':'/tmp/custom',
+ 'GITEA_APP_INI':'/tmp/absent-app.ini','TZ':'UTC','LC_ALL':'C'}.items():
+ bare+=['--setenv',k,v]
+ bare+=['--','/app/forgejo']
+ outer={'PATH':'/usr/bin:/bin','HOME':str(work/'home')}
+ run(work,'binary-version',bare+['--version'],outer)
+ run(work,'new-cli-help',bare+['admin','auth','add-oauth','--help'],outer)
+ assert '--required-claim-values' in (work/'new-cli-help.log').read_text()
+ run(work,'embedded-extract',bare+['embedded','extract','--include-vendored','--destination','/results','**'],outer)
+ records={}
+ for folder in ['public','options','templates']:
+ for f in sorted((work/'source'/folder).rglob('*')):
+ if not f.is_file():continue
+ n=str(f.relative_to(work/'source'));q=target/n
+ if not q.is_file() or digest(f)!=digest(q):raise ValueError('embedded asset missing/different: '+n)
+ records[n]=digest(f)
+ assert 'public/assets/js/index.js' in records and 'public/assets/css/index.css' in records
+ assert 'public/assets/licenses.txt' in records
+ save(work/'embedded-files.json',records)
+ readelf=subprocess.run(['/usr/bin/readelf','-l',str(binary)],capture_output=True,text=True,check=True,env={'PATH':'/usr/bin:/bin'})
+ assert 'INTERP' not in readelf.stdout
+ save(work/'verification.json',{'binary_sha256':digest(binary),'embedded_files':len(records),
+ 'embedded_file_manifest_sha256':digest(work/'embedded-files.json'),'all_public_options_templates_exact':True,
+ 'no_source_assets_libraries_or_host_config_mounted':True,
+ 'no_dynamic_interpreter':True,'new_cli_flag_present':True,'network':'bwrap --unshare-net',
+ 'source_locks_preserved':True,'architecture_approval':False,'native_admission':False})
+ print((work/'verification.json').read_text())
+
+def container(work):
+ verified=json.loads((work/'verification.json').read_text());check(work/'source/gitea',verified['binary_sha256'])
+ context=work/'container-context'
+ if context.exists():raise ValueError('container trial already exists; retain evidence before an explicit revised trial')
+ archive_path=work/'image.oci.tar'
+ if archive_path.exists():raise ValueError('retain existing OCI artifact; never overwrite it')
+ base_bytes=(INPUTS/'runtime-base-config.json').read_bytes()
+ assert hashlib.sha256(base_bytes).hexdigest()==LOCK['runtime_base_config_sha256']
+ original=json.loads(base_bytes)
+ epoch=max(LOCK['source_date_epoch'],math.ceil(datetime.datetime.fromisoformat(original['created'].replace('Z','+00:00')).timestamp()))
+ context.mkdir();shutil.copyfile(work/'source/gitea',context/'gitea')
+ os.utime(context/'gitea',(LOCK['source_date_epoch'],)*2)
+ (context/'Dockerfile').write_text('FROM '+LOCK['runtime_base']+'\nARG SOURCE_DATE_EPOCH\nLABEL org.opencontainers.image.version="'+LOCK['release_version']+'" org.opencontainers.image.revision="'+LOCK['source_commit']+'" org.railiance.source.patch.sha256="'+LOCK['patch_sha256']+'"\nCOPY --chown=0:0 --chmod=755 gitea /app/gitea/gitea\n')
+ os.utime(context/'Dockerfile',(LOCK['source_date_epoch'],)*2)
+ config=work/'docker-config';config.mkdir(exist_ok=True)
+ env={'PATH':'/usr/bin:/bin','HOME':str(work/'home'),'DOCKER_CONFIG':str(config),'DOCKER_HOST':'unix:///var/run/docker.sock'}
+ # SOURCE_DATE_EPOCH alone does not normalise COPY parent-directory mtimes.
+ # OCI output permits layer rewriting without the Docker-store unpack conflict.
+ # The epoch never predates the immutable base, preserving its exact layer prefix.
+ run(work,'container-build',['docker','buildx','build','--output','type=oci,dest='+str(archive_path)+',rewrite-timestamp=true',
+ '--no-cache','--platform',LOCK['platform'],'--pull=false','--network=none','--provenance=false','--sbom=false',
+ '--metadata-file',str(work/'image-build-metadata.json'),'--build-arg','SOURCE_DATE_EPOCH='+str(epoch),str(context)],env)
+ metadata=json.loads((work/'image-build-metadata.json').read_text())
+ with tarfile.open(archive_path) as archive:
+ index_bytes=archive.extractfile('index.json').read();index=json.loads(index_bytes)
+ assert len(index['manifests'])==1
+ descriptor=index['manifests'][0]
+ assert descriptor['platform']=={'architecture':'amd64','os':'linux'}
+ assert descriptor['digest']==metadata['containerimage.digest']
+ def blob(d):
+ value=archive.extractfile('blobs/sha256/'+d.split(':')[1]).read()
+ assert 'sha256:'+hashlib.sha256(value).hexdigest()==d
+ return value
+ manifest=json.loads(blob(descriptor['digest']))
+ config_digest=manifest['config']['digest'];config=json.loads(blob(config_digest))
+ assert config_digest==metadata['containerimage.config.digest']
+ for layer in manifest['layers']:blob(layer['digest'])
+ assert config['rootfs']['diff_ids'][:-1]==original['rootfs']['diff_ids']
+ assert config['architecture']=='amd64' and config['os']=='linux'
+ for key in ['User','Entrypoint','Cmd','Env','WorkingDir','Volumes']:
+ assert config['config'].get(key)==original['config'].get(key),key
+ labels=dict(original['config'].get('Labels',{}))
+ labels.update({'org.opencontainers.image.version':LOCK['release_version'],
+ 'org.opencontainers.image.revision':LOCK['source_commit'],'org.railiance.source.patch.sha256':LOCK['patch_sha256']})
+ assert config['config'].get('Labels')==labels
+ assert config['config']['User']=='1000:1000'
+ expected_created=datetime.datetime.fromtimestamp(epoch,datetime.timezone.utc).isoformat(timespec='seconds').replace('+00:00','Z')
+ assert config['created']==expected_created
+ found=False
+ last='blobs/sha256/'+manifest['layers'][-1]['digest'].split(':')[1]
+ with tarfile.open(fileobj=archive.extractfile(last),mode='r|*') as layer:
+ for member in layer:
+ name=member.name.lstrip('./');assert name in ('app','app/gitea','app/gitea/gitea')
+ if name=='app/gitea/gitea':
+ h=hashlib.sha256();stream=layer.extractfile(member)
+ for block in iter(lambda:stream.read(1024*1024),b''):h.update(block)
+ assert h.hexdigest()==verified['binary_sha256'];found=True
+ assert found
+ save(work/'container-result.json',{'image_manifest_digest':descriptor['digest'],'config_digest':config_digest,
+ 'oci_index_sha256':hashlib.sha256(index_bytes).hexdigest(),'archive_sha256':digest(archive_path),
+ 'artifact_path':str(archive_path),'source_epoch':LOCK['source_date_epoch'],'container_epoch':epoch,
+ 'all_base_layers_and_rootless_runtime_config_preserved':True,'all_oci_blob_digests_verified':True,
+ 'final_layer_binary_exact':True,'registry_publication':False,'deployment_tag':None,
+ 'host_or_secret_mounts':False,'privileged_containers':False,'local_runtime_image_loaded':False})
+ print((work/'container-result.json').read_text())
+
+def main():
+ parser=argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('phase',choices=['prepare','acquire-npm','build','verify','container'])
+ parser.add_argument('--workspace',required=True)
+ for n in ['source-archive','go-archive','node-archive','module-cache','go-cache-seed','npm-cache-seed']:parser.add_argument('--'+n)
+ a=parser.parse_args();work=Path(a.workspace).resolve()
+ if not str(work).startswith('/tmp/'):raise ValueError('isolated build workspace must be under /tmp')
+ if a.phase=='prepare':return prepare(a,work)
+ assert json.loads((work/'prepared.json').read_text())['lock_sha256']==digest(INPUTS/'inputs.lock.json')
+ if a.phase=='acquire-npm':
+ checked_inputs(work);env=base_env(work);env['NPM_CONFIG_OFFLINE']='false'
+ return run(work,'npm-public-acquire',['npm','ci','--ignore-scripts','--no-audit','--no-fund'],env,cwd=work/'source')
+ {'build':build,'verify':verify,'container':container}[a.phase](work)
+
+if __name__=='__main__':main()