diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-chart-projection-r1/upstream/gitea-12.5.0.tgz b/docs/changes/netkingdom-target-2026-10-17/apps-chart-projection-r1/upstream/gitea-12.5.0.tgz new file mode 100644 index 00000000..2afd9a00 Binary files /dev/null and b/docs/changes/netkingdom-target-2026-10-17/apps-chart-projection-r1/upstream/gitea-12.5.0.tgz differ diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-execution-backend-r1/RAPPS-WP-0016-T05-source-draft.md b/docs/changes/netkingdom-target-2026-10-17/apps-execution-backend-r1/RAPPS-WP-0016-T05-source-draft.md new file mode 100644 index 00000000..277f5e1a --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-execution-backend-r1/RAPPS-WP-0016-T05-source-draft.md @@ -0,0 +1,22 @@ +Source draft continuation, 2026-10-17 (Custodian preparation; no Apps assent): +`tools/forgejo_configuration_prepare.py` now supplies offline frozen-source +checks, exact proposed case-only OAuth/group-reference public projection, +metadata-only upgrade/inverse command templates and original-before/post/current +inverse guards. `tests/test_forgejo_configuration_prepare.py` exercises ten +synthetic offline cases. The preparer does not authenticate an acceptance or +observation, execute the helper/Helm, persist a transaction, or grant authority. +`activate` always refuses. It does not change the existing delivery/hand-over +receipt-only rollback or preexisting NOCHANGE paths. + +Remaining T05 implementation is concrete: bind chart 12.5.0's actual public and +private configuration projections; implement the owner committed-evidence +journal's intent/post/inverse-attempt/outcome publication and recovery; bind a +current attended owner operation session, with an independent inverse operation +and disposition/revocation checks; enforce exclusion across every Forgejo +release writer for check+mutation+readback. Existing Warden SQLite durability and +process locking alone do not establish owner authentication or all-writer +exclusion. Keep source dispatch disabled until these bindings exist. Preserve +pending and publication-ambiguous outcomes for owner reconciliation. T02/T03 +still owe the four real login/group observations and T05 the real created-only +objects/receipt backend. Existing needs-change history, human acceptance and +first-case tasks remain open; this preparation does not close them. diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-execution-backend-r1/README.md b/docs/changes/netkingdom-target-2026-10-17/apps-execution-backend-r1/README.md new file mode 100644 index 00000000..1f95d311 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-execution-backend-r1/README.md @@ -0,0 +1,97 @@ +# Apps T05 source preparation, r1 + +This packet adds real offline source mechanics on frozen Apps main +`944b30ad63ab054cc24617e374e177d34cd572da`, without claiming an operational +configuration backend. The isolated source is `/tmp/apps-execution-backend-r1/source`. +`apps-source.patch` contains one module, ten synthetic tests and an existing +RAPPS-WP-0016-T05 continuation. No canonical Apps source or status was edited, +no commit was made and no owner assent was produced. NK-WP-0048-T34 remains open. + +The module compares explicitly supplied public JSON projections against exact +proposed acceptance digests. It permits differences only in the named +`netkingdom` OAuth entry and `ENABLE_AUTO_REGISTRATION`/`ACCOUNT_LINKING` policy +keys. It preserves other OAuth sources and all unrelated public settings, +requires disabled registration and retained local sign-in, and rejects +value-bearing key shapes. It pins the actual Makefile, public values, registry +values and helper bytes. Output contains only metadata, hashes and command +templates, never OAuth/group contents or helper output. The group reference +bytes are exact acceptance-bound data; this does not establish chart key +compatibility or the runtime any-of group behavior. + +The upgrade template uses the actual Forgejo chart/version/namespace/release +and private helper stream seam, excluding unrelated Make prerequisites and +`--install`/namespace creation. The module imports no command/runtime API, +opens no files, and has no dispatch runner or capability callbacks. The private +helper is named but cannot execute. `activate()` always raises `Refused`, even +when caller-supplied session, journal or exclusion flags are supplied. A source +input is not an authenticated acceptance, observation or authority record. + +Inverse preparation binds the exact transaction scope, immutable original +before, confirmed post, current post equality and later restored revision with +the original configuration hash. Pending, unknown and already-restored records +require owner reconciliation rather than unguarded replay. Preexisting matched +configuration yields no rollback command. All inverse templates remain +non-dispatchable and require a fresh current inverse operation session; the +revoked hand-over parent and stored provenance confer no permission. The +separate control-flow candidate's `inverse_session` placement is unchanged. + +The original receipt-only rollback seam and preexisting NOCHANGE behavior are +untouched: this patch does not edit or replace any adapter or kernel step. It +does not introduce an eighth step or switch configuration ownership to a +Forgejo API mutation. + +## Actual facilities selected for owner continuation + +These are source implementation proposals within T05, not authority grants or +an assertion that the missing implementations exist. + +| Need | Selected existing owner surface | Exact remaining implementation | +| --- | --- | --- | +| Accepted case configuration | Apps `helm/forgejo-values.yaml`, registry values and existing `tools/forgejo-helm-secrets.sh`; chart 12.5.0 | Owner must publish the exact accepted public OAuth/group-reference projection and chart-compatible secret reference. A confidential projection must prove DB/admin/helper values unchanged without disclosing or emitting them. The preparer never consumes `helm get values` output. | +| Authenticated durable configuration history | Existing owner choice of committed `docs/evidence/-forgejo-netkingdom-handover.json` identified by path@commit, extended within the same repository evidence surface | Retain original intent before mutation; bind separate confirmed post, inverse attempt and verified outcome records to that immutable intent and transaction. Authenticate owner custody and main publication/readback. Stop on pending/publication ambiguity. A local file or git commit alone is not authenticated execution permission. No remote publication or new service is selected here. | +| Current operation/inverse session | Existing attended Apps owner option C (`apps-consumer-handover`), using Warden's actual `Invocation`/exact-source evidence-verification seam where applicable | Implement the actual owner provenance verifier, current disposition and revocation source. Bind principal/owner/case/acceptance/source/cluster/namespace/release/client/descriptor/transaction and distinct inverse operation. Revoke each operation attempt on success/refusal/failure. The same operator may perform the fresh inverse; no new standing agent identity is proposed. | +| Release-wide serialization | Existing Apps owner-operated release tooling capability, with attended release-freeze/reconciliation as the proposed mechanism | Owner must bind effective exclusion of manual Helm, Make and every external automation/reconciler across current-check, command and readback. No implemented enforcement was found. Helm has no CAS. Local locks, Warden journal locks and revision equality are insufficient; dispatch must stay disabled. | + +Warden `case_run.Executor` explicitly injects an evidence verifier and fixed +adapters; none ship by default. Its `_admission` rechecks current session +evidence, but that does not implement a separate Apps configuration inverse +session. `Journal` provides private SQLite, synchronous FULL and a process +lock for its own case receipts/intents; it is neither this configuration journal +nor exclusion of other Helm writers. Its code is inspected, not instantiated. +The Apps private-reader runtime-proof wrapper authenticates a different read +operation and is not reused as release-write or inverse permission. + +## Bounded writer inventory + +The archive's `Makefile`, all `tools/` source and all three workflow source +files were searched, together with the Forgejo/operator runbooks. Paths/hashes +are in `source-inventory.json`. No credentials/config from HOME were inspected. + +The direct Forgejo Helm writer is `Makefile:483` (`forgejo-deploy`), with +`Makefile:465` its server dry-run variant. The former depends on mailer ESO, +OpenBao store/operator installation and hook-template deployment; those are +outside this OAuth-only transaction. Runbook deployment commands invoke this +Make target. No independent Forgejo release writer was found in the archived +tools or workflows. Other release writers exist for inter-hub/core-hub/vergabe/ +coulomb/reuse; their configurable Make release variables and arbitrary manual +Helm access cannot be assumed excluded from Forgejo. External writers and +cluster reconciler permissions are not enumerable or enforceable from this +bounded source inventory. There is no claim of exhaustive estate writer +discovery or production writer freeze. + +## Validation and limits + +Ten tests pass under an empty HOME and allowlisted environment. The test code +blocks subprocess creation and sockets before exercising preparation. Checks +cover exact scope/source mismatch, unrelated release and OAuth policy changes, +duplicate sources, unsafe registration, value-bearing keys, case-only templates, +original-before/post/current/inverse guards, ambiguous states and preexisting +NOCHANGE. No native tool, credential read, external network, browser/MFA or +owner session was exercised. This proves offline validation mechanics only. + +Synthetic public projections intentionally do not assert the chart's actual +OAuth schema. No production accepted projection was available in the bounded +owner source. Actual chart rendering, authoritative private/public readback, +durable publication failure/recovery and all four consumer login/group probes +remain real T02/T03/T05 work. The existing T05 workplan's needs-change history +and task status remain retained in the source draft. diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-execution-backend-r1/SHA256SUMS.json b/docs/changes/netkingdom-target-2026-10-17/apps-execution-backend-r1/SHA256SUMS.json new file mode 100644 index 00000000..919d0b37 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-execution-backend-r1/SHA256SUMS.json @@ -0,0 +1,10 @@ +{ + "RAPPS-WP-0016-T05-source-draft.md": "faf5b7e38cdfd7778a6ff98d19f1ce7a3a163cd11ec321dff397e480ad99a8ad", + "README.md": "3caaa38b16e9016fe0b2f7b478e87d383c5b1bef546282727cf14d695c05b8e4", + "apps-source.patch": "f2363df928db278b0c5303e37efa53ed8397bc40725b240e69d78e3d0ad7e362", + "source-inventory.json": "b57fc904ae6baf2d73cc5c5465ee5463590a966eebe3d30b91aaeb6e8432bcd2", + "tests/test_forgejo_configuration_prepare.py": "7ca372cb5b72b8e16af4b3a277fd04613afdc90ebf77d38a223687b72fb91491", + "tests.log": "2f9d4d78cae19f752436fec5ff770e3a80e81dae9ce5caa6e048efeb28668873", + "tools/forgejo_configuration_prepare.py": "414ecf813db17ef1dd767626065e652b715c1db1f37f61b1f81303b0de7ca921", + "validation.json": "acd2ef5226c7f04396676804b4de29e6db12682739659971e0040ca0cbb494a7" +} diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-execution-backend-r1/source-inventory.json b/docs/changes/netkingdom-target-2026-10-17/apps-execution-backend-r1/source-inventory.json new file mode 100644 index 00000000..3e0d8d01 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-execution-backend-r1/source-inventory.json @@ -0,0 +1,192 @@ +{ + "apps_head": "944b30ad63ab054cc24617e374e177d34cd572da", + "scope": "bounded public source only; no secrets, runtime, native calls or network", + "files": [ + { + "path": ".forgejo/workflows/ci-smoke.yaml", + "sha256": "c17729b2144648fa92871e1c94bcb6bd8f64a4b533577ede9457593758e2b7ab" + }, + { + "path": ".gitea/workflows/inter-hub-production-deploy.yaml", + "sha256": "b8d7a8c7de94d449a9d1846f5336f606dc72cc0d3c64136be0e7e5feb48d61b9" + }, + { + "path": ".gitea/workflows/manifest-server-dry-run.yaml", + "sha256": "2b0f0a536c8aec797705b4d7329eb748734597d8593a697e628a5cae693fe3fa" + }, + { + "path": "AGENTS.md", + "sha256": "2a366a5fbb69ba235df97ee117153cbfa1af11ab579372751b4fb5dbb30c31a7" + }, + { + "path": "INTENT.md", + "sha256": "31060dc1738aa4540f78735019359a276ece39dad8afbf1be740713bebbe65e5" + }, + { + "path": "Makefile", + "sha256": "a319e264e6f04378f6b3d2c7781860fc4bac1abdb248cffa8c26065e08f2f290" + }, + { + "path": "docs/assessments/cust81-private-reader/phase-b-r1/native_runtime_proof_stdin.py", + "sha256": "1edc317e8563a120b62a6830eebb2875c144b45c57a491eb7da849095c5d1933" + }, + { + "path": "docs/evidence/nk48-t16-owner-review/owner-review-railiance-apps.json", + "sha256": "67e9ded8d37462ba04a0a224bac03a840d0c94326235474e1d3371c8ff61865d" + }, + { + "path": "docs/forgejo-on-railiance01.md", + "sha256": "a71670f0241d4bb76ea457f2120a332d3a53ec3f3098859982fb0b283f97370e" + }, + { + "path": "docs/operator-recipes.md", + "sha256": "d0c8c4b4ea0ea4d148153724e11e8396249d14609dc3095bdab3adec0132694e" + }, + { + "path": "docs/proposals/2026-10-11-forgejo-consumer-eso/owner-design.md", + "sha256": "c523949454e4c6eb58e8caee382380a561f561704136de62bf5783c592f962f2" + }, + { + "path": "helm/forgejo-registry-values.yaml", + "sha256": "a38f551734843c6367448f267c7376b1aeee010927a49690fdd7c1471e0ae450" + }, + { + "path": "helm/forgejo-values.yaml", + "sha256": "cf87a3dccd666bf8870619755afac4af64fc0bee77b0f0ea54718d6384b70d4f" + }, + { + "path": "registry/capabilities/capability.railiance.workload-deployment-tooling.md", + "sha256": "ad24b7656512f93698af3182a3187c2e32efd5e74ae69007cb86eb7cd889528f" + }, + { + "path": "tools/apps-pg-backup-dry-run.sh", + "sha256": "a468d6c80fb13e4bc5e67acbd51f11ae08d2543fe1366e846e940e7356674160" + }, + { + "path": "tools/apps-pg-restore-drill.sh", + "sha256": "ba94c3f56c54e6c899718ed3b89dc18308d2e334cfa68c04828df9f616ce9c3d" + }, + { + "path": "tools/build-database-url-secret.sh", + "sha256": "5d11320093170dbf77d0d4a390714a04491126f85703d701374c797f686ea2bd" + }, + { + "path": "tools/check-backup-lane-auth.sh", + "sha256": "1a0ee0d0d4646e92c654567a9c7304a80d4b33b4aadd513a326af0d8a2a68266" + }, + { + "path": "tools/check-oci-image.sh", + "sha256": "064b2a84fb3adfb45bbd704c718e8f0cf2fe48965d78b08c7b8be629eefeb5fe" + }, + { + "path": "tools/check-policy-nexus-binding.py", + "sha256": "0db04d2d88038bc8079c559c8406d825fc6966968e27a801feefefb263bcc244" + }, + { + "path": "tools/check-sops.sh", + "sha256": "e6ddd6c4ef78a7608d94e5ca3b36a4d25816f6e0c04cd34bb9e08eee441f4d74" + }, + { + "path": "tools/check-tools.sh", + "sha256": "50a94cd27c694731407e3ee10f44e64d20b5d44d9af21163eb00c8d685f1542d" + }, + { + "path": "tools/cnpg-backup-offsite-secret-apply.sh", + "sha256": "18a24ea2206a0775a4764779c96c7f93a2c36130790721a78c1806b8b6ac510a" + }, + { + "path": "tools/cnpg-backup-status.sh", + "sha256": "3e5b474fc81a394b6cf4e91434194f1df88e1958d15326bbfb1160a60c67bc8b" + }, + { + "path": "tools/cnpg-logical-backup.sh", + "sha256": "1ccbb608827af036fbbd5c132284227fe8a651e75e3a1abf66eb54382599cb25" + }, + { + "path": "tools/core-hub-helm-adopt.sh", + "sha256": "047d793b9e763b62980df4ab0d2cef5aa1b115a3d6510bac6d96cf6adf5856e8" + }, + { + "path": "tools/core-hub-smoke.sh", + "sha256": "5492aa72c6ecf5e3c22ac6c2b57385c594f52b216c525c531aec18a870e2c84f" + }, + { + "path": "tools/create-coulomb-social-env-secret.sh", + "sha256": "a741de560918b54166e818a8302338546dbcd93572624436c779cb0518003bc8" + }, + { + "path": "tools/forgejo-breakglass-seal.sh", + "sha256": "aef89b19f1f82acfbd565e07580295dd66037af27f0f3c4703327a2fe77fac33" + }, + { + "path": "tools/forgejo-breakglass-verify.sh", + "sha256": "e0144114468015730c415a503e906154313ee79acaeeba5c5ddf0a0b79f9b599" + }, + { + "path": "tools/forgejo-helm-secrets.sh", + "sha256": "ebc5425af168763923db9b120c4ac59e2b15f6aae4c3481946d8ccbc9c62412e" + }, + { + "path": "tools/forgejo-npm-smoke.sh", + "sha256": "eb13107d933d109302916c4663bf95f5f49e9d2d1cc7272f96e229e179b92f66" + }, + { + "path": "tools/forgejo-openbao-eso-token-apply.sh", + "sha256": "2e37b355631ef1d0f8a5741276f7c17f8c99e2d2dcf855e4e4273b1105d2309a" + }, + { + "path": "tools/forgejo-operator-bootstrap.sh", + "sha256": "77f27b016fd772d112c9f4ab1ff7dce569d28bb60291ad6ef04f5896c0465960" + }, + { + "path": "tools/forgejo-runner-registration-sops-bootstrap.sh", + "sha256": "3b5242696616dbb169e6315e9a70c7fd942724427fa24a60d820b556425782e5" + }, + { + "path": "tools/forgejo-smoke.sh", + "sha256": "f6146412cbe29f4abba9d81b3bc7a0652cf281b30a56fd7054e72f1574553869" + }, + { + "path": "tools/forgejo-verify.sh", + "sha256": "799571da79db58be2e6eee533ec20765aa9961bb74ea3865d69f48273a86f242" + }, + { + "path": "tools/inter-hub-smoke.sh", + "sha256": "068fa77a76bb3047e581398560d02d0bb517614abbb935655fe849082b4666ee" + }, + { + "path": "tools/k8s-server-dry-run.sh", + "sha256": "a6ff8018a777fd4a95a34609ca3efa22e5af314710522ca6fc02e852be5bb439" + }, + { + "path": "tools/reuse-forgejo-webhook.sh", + "sha256": "bf9dfe0be5ca3033e20495dbd8914e59cedc7092254c544c1436f6f4f850a041" + }, + { + "path": "tools/reuse-openbao-eso-token-apply.sh", + "sha256": "0ea9cf2b3767dac5cd43d51e15af86bfee366cbd55a2f9bf0e8e8b71714052d2" + }, + { + "path": "tools/reuse-webhook-smoke.sh", + "sha256": "be7b076970d02e357f2216935d257b44a880dec8005860003448ea8c69270a51" + }, + { + "path": "tools/smoke-service.sh", + "sha256": "0de60ff9e32d820ea60e3b197715a4a99167f929b69516e0b632019c046ea1a4" + } + ], + "owner_runtime_backend_found": false, + "all_writer_exclusion_found": false, + "other_public_source": [ + { + "repo": "ops-warden", + "path": "src/warden/case_run.py", + "sha256": "1bc73e30ac718fd75bbaff2a739495fc051aeaec73802ac71f0583cb2d53ab0a" + }, + { + "repo": "ops-warden", + "path": "src/warden/case_receipt_state.py", + "sha256": "a2cda7f187e77bd76d4d209505638de44a587fae0bc033ec75c23ee9ea9f73bc" + } + ] +} diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-execution-backend-r1/tests/test_forgejo_configuration_prepare.py b/docs/changes/netkingdom-target-2026-10-17/apps-execution-backend-r1/tests/test_forgejo_configuration_prepare.py new file mode 100644 index 00000000..a1c450da --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-execution-backend-r1/tests/test_forgejo_configuration_prepare.py @@ -0,0 +1,167 @@ +"""Synthetic offline tests; never acquire owner credentials or execute native tools.""" +import copy +import importlib.util +import json +from pathlib import Path +import socket +import subprocess +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location('prepare', ROOT / 'tools/forgejo_configuration_prepare.py') +m = importlib.util.module_from_spec(spec) +spec.loader.exec_module(m) + + +class PreparationTests(unittest.TestCase): + def setUp(self): + self.guards = [patch.object(subprocess, 'Popen', side_effect=AssertionError('native call')), + patch.object(socket, 'socket', side_effect=AssertionError('network call'))] + for guard in self.guards: + guard.start() + self.addCleanup(guard.stop) + self.binding = dict(principal='attended-owner', owner='railiance-apps', case='synthetic-case', + acceptance='synthetic-only', source=m.BASE, cluster='railiance01', + namespace='forgejo', release='forgejo', client='forgejo', + handover_descriptor='synthetic-descriptor') + self.before = {'image': {'tag': 'unchanged'}, 'gitea': { + 'oauth': [{'name': 'preexisting', 'provider': 'other'}], 'config': { + 'service': {'DISABLE_REGISTRATION': True}, + 'oauth2_client': {'UNRELATED': 'keep'}}}} + self.after = copy.deepcopy(self.before) + self.after['gitea']['oauth'].append({'name': 'netkingdom', 'provider': 'openidConnect', + 'existingSecret': 'synthetic-client-reference', + 'groups': ['synthetic-access-group']}) + self.after['gitea']['config']['oauth2_client'].update( + ENABLE_AUTO_REGISTRATION=False, ACCOUNT_LINKING='login') + self.contents = {p: (ROOT / p).read_bytes() for p in m.SOURCE_PATHS} + + def accepted(self, before=None, after=None): + return {'binding_sha256': m.digest(self.binding), + 'before_sha256': m.digest(self.before if before is None else before), + 'after_sha256': m.digest(self.after if after is None else after), + 'source_sha256': m.SOURCE_HASHES.copy()} + + def prepare(self, before=None, after=None): + return m.prepare(self.binding, self.before if before is None else before, + self.after if after is None else after, + self.accepted(before, after), self.contents) + + def observation(self, revision, config): + return dict(binding_sha256=m.digest(self.binding), revision=revision, + configuration_sha256=m.digest(config), status='deployed') + + def transaction(self): + return dict(id='synthetic-trial', binding_sha256=m.digest(self.binding), + state='confirmed', changed=True, + before=self.observation(7, self.before), after=self.observation(8, self.after)) + + def test_exact_projection_and_command_without_unrelated_prerequisites(self): + result = self.prepare() + self.assertFalse(result['dispatchable']) + self.assertEqual(result['command_template'][:6], + ['helm', 'upgrade', 'forgejo', 'gitea-charts/gitea', '--version', '12.5.0']) + self.assertNotIn('--install', result['command_template']) + self.assertNotIn('make', result['command_template']) + encoded = json.dumps(result) + self.assertNotIn('synthetic-access-group', encoded) + self.assertNotIn('synthetic-client-reference', encoded) + + def test_unrelated_changes_even_if_proposed_acceptance_contains_them(self): + candidates = [] + for path, value in [(('image', 'tag'), 'changed'), + (('gitea', 'config', 'oauth2_client', 'UNRELATED'), 'changed'), + (('gitea', 'config', 'service', 'ENABLE_INTERNAL_SIGNIN'), False)]: + after = copy.deepcopy(self.after) + parent = after + for key in path[:-1]: + parent = parent[key] + parent[path[-1]] = value + candidates.append(after) + after = copy.deepcopy(self.after) + after['gitea']['oauth'][0]['provider'] = 'changed' + candidates.append(after) + for after in candidates: + with self.subTest(after=m.digest(after)), self.assertRaises(m.Refused): + self.prepare(after=after) + + def test_case_scope_source_and_acceptance_mismatch(self): + for key in self.binding: + accepted = self.accepted() + original = self.binding[key] + self.binding[key] = original + '-different' + with self.subTest(key=key), self.assertRaises(m.Refused): + m.prepare(self.binding, self.before, self.after, accepted, self.contents) + self.binding[key] = original + for path in m.SOURCE_PATHS: + contents = self.contents.copy() + contents[path] += b'changed' + with self.subTest(path=path), self.assertRaises(m.Refused): + m.prepare(self.binding, self.before, self.after, self.accepted(), contents) + + def test_value_bearing_input_is_not_echoed(self): + for key in ('clientSecret', 'password', 'token', 'cookie', 'secret'): + after = copy.deepcopy(self.after) + after['gitea']['oauth'][-1][key] = 'do-not-echo' + with self.subTest(key=key), self.assertRaises(m.Refused) as caught: + self.prepare(after=after) + self.assertNotIn('do-not-echo', str(caught.exception)) + + def test_duplicate_auth_source_and_registration_refused(self): + after = copy.deepcopy(self.after) + after['gitea']['oauth'].append(after['gitea']['oauth'][-1].copy()) + with self.assertRaises(m.Refused): + self.prepare(after=after) + after = copy.deepcopy(self.after) + after['gitea']['config']['oauth2_client']['ENABLE_AUTO_REGISTRATION'] = True + with self.assertRaises(m.Refused): + self.prepare(after=after) + + def test_exact_preexisting_nochange_has_no_rollback(self): + plan = self.prepare(before=self.after) + self.assertFalse(plan['projection']['changed']) + tx = self.transaction() + tx.update(changed=False, before=tx['after']) + inverse = m.prepare_inverse(self.binding, tx, tx['after']) + self.assertEqual(inverse['action'], 'preserve-preexisting') + self.assertEqual(inverse['command_template'], []) + + def test_inverse_uses_original_revision_and_checks_current(self): + tx = self.transaction() + plan = m.prepare_inverse(self.binding, tx, tx['after']) + self.assertEqual(plan['command_template'][:4], ['helm', 'rollback', 'forgejo', '7']) + self.assertFalse(plan['dispatchable']) + for current in (self.observation(9, self.after), self.observation(8, self.before)): + with self.assertRaises(m.Refused): + m.prepare_inverse(self.binding, tx, current) + + def test_pending_or_ambiguous_transaction_requires_reconciliation(self): + for state in ('pending', 'inverse-started', 'restored', 'publication-unknown'): + tx = self.transaction() + tx['state'] = state + with self.subTest(state=state), self.assertRaises(m.Refused): + m.prepare_inverse(self.binding, tx, tx['after']) + for field in ('revision', 'configuration_sha256'): + tx = self.transaction() + tx['after'][field] = tx['before'][field] + with self.subTest(field=field), self.assertRaises(m.Refused): + m.prepare_inverse(self.binding, tx, tx['after']) + + def test_inverse_readback_requires_original_config_and_new_revision(self): + tx = self.transaction() + self.assertIn('durable-owner-confirmation-required', + m.verify_inverse_readback(tx, self.observation(9, self.before))) + for restored in (self.observation(7, self.before), self.observation(9, self.after)): + with self.assertRaises(m.Refused): + m.verify_inverse_readback(tx, restored) + + def test_no_fabricated_facility_can_activate(self): + for operation in ('configure', 'inverse'): + with self.assertRaises(m.Refused): + m.activate(operation=operation, authenticated=True, + journal={}, lock=True, inverse_session='synthetic') + + +if __name__ == '__main__': + unittest.main() diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-execution-backend-r1/validation.json b/docs/changes/netkingdom-target-2026-10-17/apps-execution-backend-r1/validation.json new file mode 100644 index 00000000..28b5fbe9 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-execution-backend-r1/validation.json @@ -0,0 +1,43 @@ +{ + "base": "944b30ad63ab054cc24617e374e177d34cd572da", + "command": [ + "/usr/bin/python3", + "-m", + "unittest", + "discover", + "-s", + "tests", + "-p", + "test_forgejo_configuration_prepare.py", + "-v" + ], + "cwd": "/tmp/apps-execution-backend-r1/source", + "environment": { + "HOME": "/tmp/apps-execution-backend-r1/empty-home", + "PATH": "/usr/bin:/bin", + "PYTHONDONTWRITEBYTECODE": "1" + }, + "exit_code": 0, + "tests_run": 10, + "synthetic_only": true, + "test_network_and_native_processes": "socket.socket and subprocess.Popen blocked inside each synthetic test", + "native_or_credential_calls": false, + "external_network": false, + "operational_backend": false, + "first_case_acceptance": false, + "canonical_apps_edits": false, + "existing_task": "RAPPS-WP-0016-T05", + "existing_netkingdom_task": "NK-WP-0048-T34", + "patch_sha256": "f2363df928db278b0c5303e37efa53ed8397bc40725b240e69d78e3d0ad7e362", + "patch_apply_check": { + "command": [ + "git", + "apply", + "--check", + "/home/worsch/the-custodian/docs/changes/netkingdom-target-2026-10-17/apps-execution-backend-r1/apps-source.patch" + ], + "cwd": "/tmp/apps-execution-backend-r1/apply-check", + "exit_code": 0, + "baseline_workplan": "archived from frozen Apps commit; no canonical checkout touched" + } +} diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/SECRET-INPUT-PROPOSAL.md b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/SECRET-INPUT-PROPOSAL.md new file mode 100644 index 00000000..3ea51372 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/SECRET-INPUT-PROPOSAL.md @@ -0,0 +1,62 @@ +# Separate source proposal: private client-secret file, never secret argv + +The reviewed seven-path extension and this release build do **not** add a new +secret input mechanism. Current Forgejo 11.0.3's `secret` CLI flag has no private +file/descriptor/environment input configured; the chart's automatic quoted +`${GITEA_OAUTH_SECRET_}` becomes a value-bearing `--secret` argument at +runtime. There is no admitted existing alternate path in the bounded source. +No actual secret or auth operation was used during this build. + +A concrete owner source continuation is: + +1. Add a distinct `--secret-file` StringFlag to Forgejo add/update OAuth CLI. + Resolve it only inside the existing owner workload after operation admission. + Reject simultaneous `--secret`/`--secret-file` and reject empty file paths. + For update, omission preserves the existing stored secret, while an explicitly + selected private input must succeed completely before any DB source update. + Errors contain only fixed reasons, never secret bytes or serialized config. +2. Read a bounded regular file with a non-following open and file-descriptor + metadata checks; reject symlinks/FIFO/device/oversize/short or failed reads, + ownership/mode mismatch, writable-by-others paths and empty values. Parent + directories and the exact mount identity must be owner-controlled. Preserve + exact secret bytes; do not trim or log them. The accepted secret encoding and + maximum length must match the existing minted-client contract. Keep the + transient string out of command tracing, diagnostics and public receipts. + The process already holds the secret for the existing DB configuration; this + proposal changes the input boundary, not its custody authority. +3. Use the existing `forgejo/forgejo-netkingdom-oidc-client` Secret, + key `CLIENT_SECRET`, through a **reviewed read-only file subPath mount** into + the existing configure init container. A file subPath avoids the projected + Secret symlink leaf and permits non-following file checks. The exact mount, + UID/GID/mode and allowed reader set require owner/runtime admission; no file + has been mounted or permission asserted here. Do not add an alias key or a + new remote read/ACL. Rotation/restart behavior of a subPath mount needs the + existing owner refresh contract; it does not update in place. +4. The current chart helper would automatically add `--secret` even when a new + `secretFile` key is supplied. Therefore a **separately reviewed chart-source + patch** is also required. In chart 12.5.0 `_helpers.tpl`'s + `gitea.oauth_settings`, change the default-secret insertion condition from + `if not (hasKey $values "secret")` to + `if and (not (hasKey $values "secret")) (not (hasKey $values "secretFile"))`. + Reject both keys together. The existing generic key formatter then emits the + implemented `--secret-file` flag containing only the fixed private path. + Omit `existingSecret`, the indexed secret environment entry and the legacy + `secret` key for this case. Do not put the secret itself in any public value, + Make variable, agent argument, Helm override or receipt. + +This is a source proposal, not an implemented/accepted flag or chart feature. +It must be implemented and independently tested as a new exact source revision, +with CLI add/update preservation, source serialization unchanged except intended +secret replacement, mutual exclusion, symlink/permission/file-race/error cases, +no argv/env/log/receipt value leakage and actual chart command rendering. A +secret-file path flag alone is insufficient if the helper still injects +`--secret`. The source/chart archives and owner descriptors must then be +reviewed/re-frozen. Existing type 1.3 does not admit this mechanism implicitly. + +The agent can prepare public source, references and readback schema; the actual +workload reads the value only after the authenticated access-engine decision, +current operation session and private-runtime admission. No standing agent +credential or value-bearing agent argv is proposed. The T34 owner backend still +owes exact application before/post readback, durable journal, all-writer +exclusion, current operation/inverse sessions and the application-state inverse. +This source proposal supplies none of those grants or implementations. diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/apps-release-build-source.patch b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/apps-release-build-source.patch new file mode 100644 index 00000000..f7756fb9 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/apps-release-build-source.patch @@ -0,0 +1,908 @@ +--- 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 = ¤tClaimTransport{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','--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() diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/attempt1-container-build-result.json b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/attempt1-container-build-result.json new file mode 100644 index 00000000..ae4cf22e --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/attempt1-container-build-result.json @@ -0,0 +1,24 @@ +{ + "argv": [ + "docker", + "buildx", + "build", + "--load", + "--platform", + "linux/amd64", + "--pull=false", + "--network=none", + "--provenance=false", + "--sbom=false", + "--iidfile", + "/tmp/apps-forgejo-release-repro-r1/image-id", + "--metadata-file", + "/tmp/apps-forgejo-release-repro-r1/image-build-metadata.json", + "--build-arg", + "SOURCE_DATE_EPOCH=1752105237", + "/tmp/apps-forgejo-release-repro-r1/container-context" + ], + "phase": "container-build", + "returncode": 0, + "seconds": 13.55 +} diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/attempt1-container-save-result.json b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/attempt1-container-save-result.json new file mode 100644 index 00000000..899c175c --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/attempt1-container-save-result.json @@ -0,0 +1,13 @@ +{ + "argv": [ + "docker", + "image", + "save", + "--output", + "/tmp/apps-forgejo-release-repro-r1/image.tar", + "sha256:ffae6c666403ab2f995dc017069bcb1a9aea9c6e9d21a55b2ae5a7df5d71ba64" + ], + "phase": "container-save", + "returncode": 1, + "seconds": 0.05 +} diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/attempt1-embedded-extract-result.json b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/attempt1-embedded-extract-result.json new file mode 100644 index 00000000..449a72b2 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/attempt1-embedded-extract-result.json @@ -0,0 +1,61 @@ +{ + "argv": [ + "bwrap", + "--unshare-net", + "--unshare-pid", + "--proc", + "/proc", + "--dev", + "/dev", + "--tmpfs", + "/tmp", + "--dir", + "/app", + "--ro-bind", + "/tmp/apps-forgejo-release-repro-r1/source/gitea", + "/app/forgejo", + "--bind", + "/tmp/apps-forgejo-release-repro-r1/embedded-extracted", + "/results", + "--chdir", + "/tmp", + "--clearenv", + "--setenv", + "PATH", + "/nonexistent", + "--setenv", + "HOME", + "/tmp/home", + "--setenv", + "USER", + "builder", + "--setenv", + "USERNAME", + "builder", + "--setenv", + "GITEA_WORK_DIR", + "/tmp/work", + "--setenv", + "GITEA_CUSTOM", + "/tmp/custom", + "--setenv", + "GITEA_APP_INI", + "/tmp/absent-app.ini", + "--setenv", + "TZ", + "UTC", + "--setenv", + "LC_ALL", + "C", + "--", + "/app/forgejo", + "embedded", + "extract", + "--include-vendored", + "--destination", + "/results" + ], + "phase": "embedded-extract", + "returncode": 1, + "seconds": 0.15 +} diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/buildkit-build-repro.md b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/buildkit-build-repro.md new file mode 100644 index 00000000..44c22117 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/buildkit-build-repro.md @@ -0,0 +1,67 @@ +# Build reproducibility + +## Reproducing the pinned dependencies + +Reproducing the pinned dependencies is supported since BuildKit v0.11. + +e.g., +```bash +buildctl build --frontend dockerfile.v0 --local dockerfile=. --local context=. --source-policy-file policy.json +``` + +An example `policy.json`: +```json +{ + "rules": [ + { + "action": "CONVERT", + "selector": { + "identifier": "docker-image://docker.io/library/alpine:latest" + }, + "updates": { + "identifier": "docker-image://docker.io/library/alpine:latest@sha256:4edbd2beb5f78b1014028f4fbb99f3237d9561100b6881aabbf5acce2c4f9454" + } + }, + { + "action": "CONVERT", + "selector": { + "identifier": "https://raw.githubusercontent.com/moby/buildkit/v0.10.1/README.md" + }, + "updates": { + "attrs": {"http.checksum": "sha256:6e4b94fc270e708e1068be28bd3551dc6917a4fc5a61293d51bb36e6b75c4b53"} + } + } + ] +} +``` + +Any source type is supported, but how to pin a source depends on the type. + +## `SOURCE_DATE_EPOCH` +[`SOURCE_DATE_EPOCH`](https://reproducible-builds.org/docs/source-date-epoch/) is the convention for pinning timestamps to a specific value. + +The Dockerfile frontend supports consuming the `SOURCE_DATE_EPOCH` value as a special build arg, since BuildKit 0.11. +Minimal support is also available on older BuildKit when using Dockerfile 1.5 frontend. + +```console +buildctl build --frontend dockerfile.v0 --opt build-arg:SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct) ... +``` +The `buildctl` CLI (>= v0.13) and Docker Buildx (>= 0.10) automatically propagate the `$SOURCE_DATE_EPOCH` environment value from the client host to the `SOURCE_DATE_EPOCH` build arg. + +The build arg value is used for: +- the `created` timestamp in the [OCI Image Config](https://github.com/opencontainers/image-spec/blob/main/config.md#properties) +- the `created` timestamp in the `history` objects in the [OCI Image Config](https://github.com/opencontainers/image-spec/blob/main/config.md#properties) +- the `org.opencontainers.image.created` annotation in the [OCI Image Index](https://github.com/opencontainers/image-spec/blob/main/annotations.md#pre-defined-annotation-keys) +- the timestamp of the files exported with the `local` exporter +- the timestamp of the files exported with the `tar` exporter + +To apply the build arg value to the timestamps of the files inside the image, specify `rewrite-timestamp=true` as an image exporter option: +``` +--output type=image,name=docker.io/username/image,push=true,rewrite-timestamp=true +``` + +The `rewrite-timestamp` option is available since BuildKit v0.13. +See [v0.12 documentation](https://github.com/moby/buildkit/blob/v0.12/docs/build-repro.md#caveats) for dealing with timestamps +in BuildKit v0.12 and v0.11. + +See also the [documentation](/frontend/dockerfile/docs/reference.md#buildkit-built-in-build-args) of the Dockerfile frontend. diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/buildkit-document-source.json b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/buildkit-document-source.json new file mode 100644 index 00000000..1409c629 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/buildkit-document-source.json @@ -0,0 +1,5 @@ +{ + "url": "https://raw.githubusercontent.com/moby/buildkit/v0.27.1/docs/build-repro.md", + "sha256": "1e503e37e994311600745f61ed0173a7ae4217ce307cf72513230e534f5833ca", + "primary_source": true +} diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/container-build-result.json b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/container-build-result.json new file mode 100644 index 00000000..7e88dbe7 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/container-build-result.json @@ -0,0 +1,24 @@ +{ + "argv": [ + "docker", + "buildx", + "build", + "--output", + "type=oci,dest=/tmp/apps-forgejo-release-repro-r1/image.oci.tar,rewrite-timestamp=true", + "--no-cache", + "--platform", + "linux/amd64", + "--pull=false", + "--network=none", + "--provenance=false", + "--sbom=false", + "--metadata-file", + "/tmp/apps-forgejo-release-repro-r1/image-build-metadata.json", + "--build-arg", + "SOURCE_DATE_EPOCH=1752120742", + "/tmp/apps-forgejo-release-repro-r1/container-context" + ], + "phase": "container-build", + "returncode": 0, + "seconds": 22.18 +} diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/container-epoch.json b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/container-epoch.json new file mode 100644 index 00000000..14769c04 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/container-epoch.json @@ -0,0 +1 @@ +{"source_epoch": 1752105237, "base_created": "2025-07-10T04:12:21.034425519Z", "container_epoch": 1752120742, "derivation": "max(source epoch,ceil immutable base created timestamp)"} \ No newline at end of file diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/container-export-refinement.json b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/container-export-refinement.json new file mode 100644 index 00000000..b48a51aa --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/container-export-refinement.json @@ -0,0 +1,9 @@ +{ + "image_build_succeeded": true, + "failed_phase": "image export only", + "reason": "Docker29 containerd store addresses untagged image by manifest digest, not iid config digest", + "config_digest": "sha256:ffae6c666403ab2f995dc017069bcb1a9aea9c6e9d21a55b2ae5a7df5d71ba64", + "manifest_digest": "sha256:7cee81d2fce0b5a189de9702f3e800684dd4155176110469a911f0d7ac188f10", + "resume_exact_existing_image_no_rebuild": true, + "same_trial": true +} diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/container-layer-timestamp-finding.json b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/container-layer-timestamp-finding.json new file mode 100644 index 00000000..36c1c032 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/container-layer-timestamp-finding.json @@ -0,0 +1,52 @@ +{ + "source_binary_bytes_identical": true, + "layer_headers": { + "image.tar": [ + { + "path": "app", + "mtime": 1752120740, + "uid": 0, + "gid": 0, + "mode": 493 + }, + { + "path": "app/gitea", + "mtime": 1792273215, + "uid": 0, + "gid": 0, + "mode": 493 + }, + { + "path": "app/gitea/gitea", + "mtime": 1752105237, + "uid": 0, + "gid": 0, + "mode": 493 + } + ], + "image-repeat.tar": [ + { + "path": "app", + "mtime": 1752120740, + "uid": 0, + "gid": 0, + "mode": 493 + }, + { + "path": "app/gitea", + "mtime": 1792273359, + "uid": 0, + "gid": 0, + "mode": 493 + }, + { + "path": "app/gitea/gitea", + "mtime": 1752105237, + "uid": 0, + "gid": 0, + "mode": 493 + } + ] + }, + "finding": "app/gitea parent directory mtime changes on COPY despite fixed input file mtime; explicit layer timestamp rewriting needed" +} diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/container-reproduction-attempt1.json b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/container-reproduction-attempt1.json new file mode 100644 index 00000000..534ef128 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/container-reproduction-attempt1.json @@ -0,0 +1,12 @@ +{ + "no_cache_repeat": true, + "separate_context_from_independent_binary": true, + "returncode": 0, + "seconds": 11.14, + "identical_output_digests": false, + "digests": { + "containerimage.digest": "sha256:c24e0097f53f31308312ca2f8e411869a36860a7e54c24587e4cde346bbbd1f2", + "containerimage.config.digest": "sha256:f91ba4f17fbac1f563ccaef4f30aa4d9e2f59a950f75df388d78b57f73b3bf1f" + }, + "registry_publication": false +} \ No newline at end of file diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/container-result.json b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/container-result.json new file mode 100644 index 00000000..6d303b13 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/container-result.json @@ -0,0 +1,17 @@ +{ + "all_base_layers_and_rootless_runtime_config_preserved": true, + "all_oci_blob_digests_verified": true, + "archive_sha256": "e0e74653321383e133485191f0ac5af53aafb065fb5809e20ede2808cec91539", + "artifact_path": "/tmp/apps-forgejo-release-repro-r1/image.oci.tar", + "config_digest": "sha256:e9e81da43c5ea6895a95b6304337a40874e3275833be5f21fc6ffc104f79a2af", + "container_epoch": 1752120742, + "deployment_tag": null, + "final_layer_binary_exact": true, + "host_or_secret_mounts": false, + "image_manifest_digest": "sha256:9d12779f0936b9ccdbde402a9869eaad8bb91ba724d579b6c2983e8e58dabf96", + "local_runtime_image_loaded": false, + "oci_index_sha256": "24bd0e22e815dbc3470cad5a9dda509e68ad6bc751f2bfae85d02d95f8bc8cf5", + "privileged_containers": false, + "registry_publication": false, + "source_epoch": 1752105237 +} diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/docker-prototype-container-build-result.json b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/docker-prototype-container-build-result.json new file mode 100644 index 00000000..ae4cf22e --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/docker-prototype-container-build-result.json @@ -0,0 +1,24 @@ +{ + "argv": [ + "docker", + "buildx", + "build", + "--load", + "--platform", + "linux/amd64", + "--pull=false", + "--network=none", + "--provenance=false", + "--sbom=false", + "--iidfile", + "/tmp/apps-forgejo-release-repro-r1/image-id", + "--metadata-file", + "/tmp/apps-forgejo-release-repro-r1/image-build-metadata.json", + "--build-arg", + "SOURCE_DATE_EPOCH=1752105237", + "/tmp/apps-forgejo-release-repro-r1/container-context" + ], + "phase": "container-build", + "returncode": 0, + "seconds": 13.55 +} diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/docker-prototype-container-result.json b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/docker-prototype-container-result.json new file mode 100644 index 00000000..22601c1c --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/docker-prototype-container-result.json @@ -0,0 +1,12 @@ +{ + "all_base_layers_and_rootless_runtime_config_preserved": true, + "archive_sha256": "dca200a1aae8c62c4003e9d1817548b4c6dea11cee19a7687e92dbca4859d9f9", + "config_digest": "sha256:ffae6c666403ab2f995dc017069bcb1a9aea9c6e9d21a55b2ae5a7df5d71ba64", + "deployment_tag": null, + "final_layer_binary_exact": true, + "host_or_secret_mounts": false, + "local_export_reference": "sha256:7cee81d2fce0b5a189de9702f3e800684dd4155176110469a911f0d7ac188f10", + "local_image_id": "sha256:7cee81d2fce0b5a189de9702f3e800684dd4155176110469a911f0d7ac188f10", + "privileged_containers": false, + "registry_publication": false +} diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/docker-prototype-image-build-metadata.json b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/docker-prototype-image-build-metadata.json new file mode 100644 index 00000000..92ffe035 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/docker-prototype-image-build-metadata.json @@ -0,0 +1,52 @@ +{ + "buildx.build.provenance": { + "builder": { + "id": "" + }, + "buildType": "https://mobyproject.org/buildkit@v1", + "materials": [ + { + "uri": "pkg:docker/code.forgejo.org/forgejo/forgejo?digest=sha256:41649dff5311664097d29b4d80620403f0b27e7c0eee0b8af79b132a37f3807b\u0026platform=linux%2Famd64", + "digest": { + "sha256": "41649dff5311664097d29b4d80620403f0b27e7c0eee0b8af79b132a37f3807b" + } + } + ], + "invocation": { + "configSource": { + "entryPoint": "Dockerfile" + }, + "parameters": { + "frontend": "dockerfile.v0", + "args": { + "build-arg:SOURCE_DATE_EPOCH": "1752105237", + "force-network-mode": "none" + }, + "locals": [ + { + "name": "context" + }, + { + "name": "dockerfile" + } + ] + }, + "environment": { + "platform": "linux/amd64" + } + } + }, + "buildx.build.ref": "default/default/uhbf0ilfuaklbkxp6x34k9ci4", + "containerimage.config.digest": "sha256:ffae6c666403ab2f995dc017069bcb1a9aea9c6e9d21a55b2ae5a7df5d71ba64", + "containerimage.descriptor": { + "mediaType": "application/vnd.docker.distribution.manifest.v2+json", + "digest": "sha256:7cee81d2fce0b5a189de9702f3e800684dd4155176110469a911f0d7ac188f10", + "size": 2850, + "platform": { + "architecture": "amd64", + "os": "linux" + } + }, + "containerimage.digest": "sha256:7cee81d2fce0b5a189de9702f3e800684dd4155176110469a911f0d7ac188f10", + "image.name": "moby-dangling@sha256:7cee81d2fce0b5a189de9702f3e800684dd4155176110469a911f0d7ac188f10" +} \ No newline at end of file diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/earlier-metadata-observation.json b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/earlier-metadata-observation.json new file mode 100644 index 00000000..07b92a9b --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/earlier-metadata-observation.json @@ -0,0 +1,10 @@ +{ + "initial_observation": "Frozen original source-inventory hash did not match current public metadata; retained as an adverse comparison, not source drift.", + "root_provenance_commit": "16236ef", + "resolution": "Exact per-record reconstruction verifies original bytes. Only public identifier notation changed; frozen SHA map unmodified.", + "original_sha256": "77aabbe413aae6ac31c342304afa200d09ff3c74e3fc9f18d2af6919a816ab94", + "current_sha256": "866641169b73aa6e56d4f8f3d497eda28229bdf2aafe98daf5fefd09258338a1", + "reconstructor_sha256": "ec20b3378ece29dcf7faf4ad5e610e1e85bfa260121f2b8c537665f9530466e0", + "published_provenance_sha256": "770a5831999df8a8dc1b66dc65b738f58bedd4eb0dab56127f8d37b2b10107b2", + "reconstruction_verified": true +} diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/embedded-and-static-backend-result.json b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/embedded-and-static-backend-result.json new file mode 100644 index 00000000..0b100b9a --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/embedded-and-static-backend-result.json @@ -0,0 +1,16 @@ +{ + "argv": [ + "make", + "GOFLAGS=-trimpath -buildvcs=false -p=4", + "RELEASE_VERSION=11.0.3+nk48.f2788b3f", + "FORGEJO_VERSION=11.0.3+nk48.f2788b3f", + "FORGEJO_VERSION_API=11.0.3+nk48.f2788b3f", + "TAGS=bindata timetzdata sqlite sqlite_unlock_notify", + "go-check", + "generate-backend", + "static-executable" + ], + "phase": "embedded-and-static-backend", + "returncode": 0, + "seconds": 288.85 +} diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/failure-controls.json b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/failure-controls.json new file mode 100644 index 00000000..6e4ab574 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/failure-controls.json @@ -0,0 +1,16 @@ +{ + "controls": [ + { + "case": "tampered-source", + "refused": true, + "exitcode": 1 + }, + { + "case": "existing-workspace", + "refused": true, + "exitcode": 1 + } + ], + "tampered_source_workspace_not_created": true, + "existing_trial_not_overwritten": true +} diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/finalization-refinements.json b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/finalization-refinements.json new file mode 100644 index 00000000..f7697654 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/finalization-refinements.json @@ -0,0 +1,7 @@ +{ + "initial_patch_harness": "Python difflib omitted no-newline marker for immutable base-config JSON; git apply refused. Corrected generator emits standard marker without changing postimage bytes.", + "second_harness": "Dry-run assertion expected python3 prefix but owner target correctly uses absolute /usr/bin/python3 -I. Assertion corrected to exact emitted command.", + "result": "Final patch git apply --check and isolated apply pass; all six owner postimages exact; make dry run emits exactly two isolated build/container commands.", + "embedded_patch_whitespace": "git apply warned about literal context whitespace inside newly added immutable patch file; --whitespace=nowarn preserves byte-exact reviewed patch. No whitespace fix or source change.", + "build_restart": false +} diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/first-workspace-normalized-backend-result.json b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/first-workspace-normalized-backend-result.json new file mode 100644 index 00000000..85ac68f2 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/first-workspace-normalized-backend-result.json @@ -0,0 +1,81 @@ +{ + "argv": [ + "bwrap", + "--unshare-net", + "--ro-bind", + "/usr", + "/usr", + "--ro-bind", + "/lib", + "/lib", + "--ro-bind", + "/lib64", + "/lib64", + "--symlink", + "usr/bin", + "/bin", + "--proc", + "/proc", + "--dev", + "/dev", + "--tmpfs", + "/tmp", + "--bind", + "/tmp/apps-forgejo-release-build-r1", + "/tmp/apps-forgejo-release-build-r1", + "--bind", + "/tmp/apps-forgejo-release-repro-r1/gocache", + "/tmp/apps-forgejo-release-repro-r1/gocache", + "--ro-bind", + "/tmp/forgejo-independent-r1/go", + "/tmp/forgejo-independent-r1/go", + "--ro-bind", + "/tmp/forgejo-independent-r1/gomodcache", + "/tmp/forgejo-independent-r1/gomodcache", + "--chdir", + "/tmp/apps-forgejo-release-build-r1/source", + "--", + "env", + "-i", + "PATH=/tmp/apps-forgejo-release-build-r1/node-v22.15.0-linux-x64/bin:/tmp/forgejo-independent-r1/go/bin:/usr/bin:/bin", + "HOME=/tmp/apps-forgejo-release-build-r1/home", + "NPM_CONFIG_USERCONFIG=/tmp/apps-forgejo-release-build-r1/empty.npmrc", + "NPM_CONFIG_GLOBALCONFIG=/tmp/apps-forgejo-release-build-r1/empty-global.npmrc", + "NPM_CONFIG_CACHE=/tmp/apps-forgejo-release-build-r1/npm-cache", + "NPM_CONFIG_REGISTRY=https://registry.npmjs.org", + "NPM_CONFIG_AUDIT=false", + "NPM_CONFIG_FUND=false", + "NPM_CONFIG_UPDATE_NOTIFIER=false", + "PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1", + "GOTOOLCHAIN=local", + "GOPROXY=off", + "GOSUMDB=off", + "GOVCS=*:off", + "GOPATH=/tmp/apps-forgejo-release-build-r1/gopath", + "GOMODCACHE=/tmp/forgejo-independent-r1/gomodcache", + "GOCACHE=/tmp/apps-forgejo-release-repro-r1/gocache", + "CGO_ENABLED=1", + "GOMAXPROCS=4", + "GOFLAGS=-trimpath -buildvcs=false -p=4", + "SOURCE_DATE_EPOCH=1752105237", + "TZ=UTC", + "LC_ALL=C", + "NPM_CONFIG_OFFLINE=true", + "NODE_OPTIONS=--max-old-space-size=4096", + "GIT_CONFIG_NOSYSTEM=1", + "GIT_CONFIG_GLOBAL=/dev/null", + "GIT_TERMINAL_PROMPT=0", + "make", + "GOFLAGS=-trimpath -buildvcs=false -p=4", + "RELEASE_VERSION=11.0.3+nk48.f2788b3f", + "FORGEJO_VERSION=11.0.3+nk48.f2788b3f", + "FORGEJO_VERSION_API=11.0.3+nk48.f2788b3f", + "TAGS=bindata timetzdata sqlite sqlite_unlock_notify", + "go-check", + "generate-backend", + "static-executable" + ], + "phase": "normalized-embedded-and-static-backend", + "returncode": 0, + "seconds": 17.39 +} diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/first-workspace-normalized-build.json b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/first-workspace-normalized-build.json new file mode 100644 index 00000000..769b504e --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/first-workspace-normalized-build.json @@ -0,0 +1,9 @@ +{ + "binary_sha256": "4df429454b0289c6df76d7fd95d41394ebc286191809349b2c1e655b6be494e4", + "explicit_make_GOFLAGS": true, + "file_and_directory_times_normalized": true, + "owner_approval": false, + "predecessor_sha256": "bc34e12a8794e556764ab2325c31615f6ddbcc7a314aabf106b580e470189911", + "refinement_same_trial": true, + "source_locks_preserved": true +} diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/frontend-reproduction.json b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/frontend-reproduction.json new file mode 100644 index 00000000..19e2c974 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/frontend-reproduction.json @@ -0,0 +1,6 @@ +{ + "two_independent_workspace_frontend_builds": true, + "asset_files": 628, + "byte_identical": true, + "different_paths": [] +} diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/frontend-result.json b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/frontend-result.json new file mode 100644 index 00000000..6df241dd --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/frontend-result.json @@ -0,0 +1,13 @@ +{ + "argv": [ + "make", + "GOFLAGS=-trimpath -buildvcs=false -p=4", + "RELEASE_VERSION=11.0.3+nk48.f2788b3f", + "FORGEJO_VERSION=11.0.3+nk48.f2788b3f", + "FORGEJO_VERSION_API=11.0.3+nk48.f2788b3f", + "frontend" + ], + "phase": "frontend", + "returncode": 0, + "seconds": 108.61 +} diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/image-build-metadata.json b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/image-build-metadata.json new file mode 100644 index 00000000..38241ef9 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/image-build-metadata.json @@ -0,0 +1,55 @@ +{ + "buildx.build.provenance": { + "builder": { + "id": "" + }, + "buildType": "https://mobyproject.org/buildkit@v1", + "materials": [ + { + "uri": "pkg:docker/code.forgejo.org/forgejo/forgejo?digest=sha256:41649dff5311664097d29b4d80620403f0b27e7c0eee0b8af79b132a37f3807b\u0026platform=linux%2Famd64", + "digest": { + "sha256": "41649dff5311664097d29b4d80620403f0b27e7c0eee0b8af79b132a37f3807b" + } + } + ], + "invocation": { + "configSource": { + "entryPoint": "Dockerfile" + }, + "parameters": { + "frontend": "dockerfile.v0", + "args": { + "build-arg:SOURCE_DATE_EPOCH": "1752120742", + "force-network-mode": "none", + "no-cache": "" + }, + "locals": [ + { + "name": "context" + }, + { + "name": "dockerfile" + } + ] + }, + "environment": { + "platform": "linux/amd64" + } + } + }, + "buildx.build.ref": "default/default/n1p4kafj9m6flpod8a9dbtxkm", + "containerimage.config.digest": "sha256:e9e81da43c5ea6895a95b6304337a40874e3275833be5f21fc6ffc104f79a2af", + "containerimage.descriptor": { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:9d12779f0936b9ccdbde402a9869eaad8bb91ba724d579b6c2983e8e58dabf96", + "size": 2841, + "annotations": { + "org.opencontainers.image.created": "2025-07-10T04:12:22Z" + }, + "platform": { + "architecture": "amd64", + "os": "linux" + } + }, + "containerimage.digest": "sha256:9d12779f0936b9ccdbde402a9869eaad8bb91ba724d579b6c2983e8e58dabf96" +} \ No newline at end of file diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/make-dry-run.txt b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/make-dry-run.txt new file mode 100644 index 00000000..be40a1c8 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/make-dry-run.txt @@ -0,0 +1,2 @@ +/usr/bin/python3 -I tools/forgejo-release-build.py build --workspace "/tmp/railiance-apps-forgejo-release" +/usr/bin/python3 -I tools/forgejo-release-build.py container --workspace "/tmp/railiance-apps-forgejo-release" diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/node-official-SHASUMS256.txt b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/node-official-SHASUMS256.txt new file mode 100644 index 00000000..686b1de5 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/node-official-SHASUMS256.txt @@ -0,0 +1,41 @@ +049718cc0ce4f58de90f7b752b82a78868a21fe7685af1c603ba1d22ae8a4d67 node-v22.15.0-aix-ppc64.tar.gz +1013d381e8a4ebe64484eed07db2637a570090fdef87924bf0beb92d9ba7cc92 node-v22.15.0-arm64.msi +92eb58f54d172ed9dee320b8450f1390db629d4262c936d5c074b25a110fed02 node-v22.15.0-darwin-arm64.tar.gz +6e278a107d50da24b644dd26810a639a5f8ca67b55086e6b693caabcbb759912 node-v22.15.0-darwin-arm64.tar.xz +f7f42bee60d602783d3a842f0a02a2ecd9cb9d7f6f3088686c79295b0222facf node-v22.15.0-darwin-x64.tar.gz +7dab3f93551d88f1e63db6b32bae6d4858e16740e9849ebbeac1d43f5055d8f0 node-v22.15.0-darwin-x64.tar.xz +cda8bbbfb4f7fb19b65efd5faabc97ccea1e94422e7066b9a1e70280c1ca6453 node-v22.15.0-headers.tar.gz +1f0377a9f3dbb85959d38a6ebc6e7e189f520f509488c401be4970d8718a14e9 node-v22.15.0-headers.tar.xz +c3582722db988ed1eaefd590b877b86aaace65f68746726c1f8c79d26e5cc7de node-v22.15.0-linux-arm64.tar.gz +d68adf72c531f1118bee75b20ffbc5911accfda5e73454a798625464b40a4adf node-v22.15.0-linux-arm64.tar.xz +639a3ee3217049ba20e6f05651c7281a9d007111196387eb177081af8851e52f node-v22.15.0-linux-armv7l.tar.gz +998da599863b8104992472ca4e738842b5e9b76b3665ea8bc13bd1ca52019426 node-v22.15.0-linux-armv7l.tar.xz +a744107732546a3b112630cde1cc2db681559668cea36bee851348ed036c101c node-v22.15.0-linux-ppc64le.tar.gz +e12384dd6d8e80ee1cdb8cf0f38545d3cc2a5b99f7dd2c6ae05067222d39ff7a node-v22.15.0-linux-ppc64le.tar.xz +600afa22128e5d1b7d472109b8f9f93ace161dab3c1a8a642895675471916d86 node-v22.15.0-linux-s390x.tar.gz +308e14b2b20445e03fbec920921b6b7f579832516b3e826b0d67be8acf0ac6f6 node-v22.15.0-linux-s390x.tar.xz +29d1c60c5b64ccdb0bc4e5495135e68e08a872e0ae91f45d9ec34fc135a17981 node-v22.15.0-linux-x64.tar.gz +dafe2e8f82cb97de1bd10db9e2ec4c07bbf53389b0799b1e095a918951e78fd4 node-v22.15.0-linux-x64.tar.xz +0bc096a279cd7cbc57bf3a6c6570f3ca03b3ab684d1878a1425919e9b6b76317 node-v22.15.0.pkg +8e64edbc70a7188873e0f4f07c812fa747c27c477d558b23dcb135bdf5e32298 node-v22.15.0.tar.gz +e7c4226d1d92f33ad854d6da4f7e519e77690b8e73f93496881f8c539174d9df node-v22.15.0.tar.xz +d0615f16a280f4660a6c83d472a0b2e72122e47f4c82754a5ed950d54d0cbe3d node-v22.15.0-win-arm64.7z +737cd7046d96575c2d6cd36e0355afba54b79296b0f403ef4b3b1b5852b10ab6 node-v22.15.0-win-arm64.zip +45471a4f77cdaacce971ab7a6698659ed872606f7b4b0aecdfe5689627134cb2 node-v22.15.0-win-x64.7z +06067d4f0d463f90ed803d5eca5b039a05dec5d70fc7b7cc254803a59bd0e27c node-v22.15.0-win-x64.zip +f9534de57244052cb245ca3af29ac40e947b5735b05b37a28efac7bc82809a8a node-v22.15.0-win-x86.7z +961e362568b91340b0229d27a64e3925ce5d807898a86074d61cab578f151843 node-v22.15.0-win-x86.zip +499d4a47b9db6db47114a31ceab69c69a7265900a29a5da53746c00d13dbad88 node-v22.15.0-x64.msi +1d884cdc5517126c2da00e23b445050a2f1e8b7bcaa57e0feb2c2481d347fb0a node-v22.15.0-x86.msi +ebdab2adc9ff5fb09f852b665875af35138b9ee5725a3927bb85d7cfd36e059f win-arm64/node.exe +6c43922c747e0c07fcc4ceb64f08e98854a5c1b31665764076f5783556973d16 win-arm64/node.lib +fb437ae33e9e5fff6792ba7a1e81d1a1dab5e346b57328e9a0d2ec7b9bdb309d win-arm64/node_pdb.7z +7785c2aec7a86efce7707a1abc7dee58bd11e610f748f74e243789f9daff1e13 win-arm64/node_pdb.zip +77bdff912b1c569b3e693fe126f619337c3e9d73dafbc4d0bf1d4f1f6a145761 win-x64/node.exe +e731ad9af05eabe6bd941442b19dd6037f559a19a2fa8df2a8297ccb46ed7577 win-x64/node.lib +8c886b364ac8c65bef429d9d127f457e5c5755d19c4df294e9d187763dc6c3ea win-x64/node_pdb.7z +132d18a1f522d20bc63d66b0eabe8089a2643ac61e1585d0269013f01ef1baef win-x64/node_pdb.zip +0c20613ca23c8b8e7ca3d1ee5da9173dd9289f9833cff18649c192e4ce42c9b4 win-x86/node.exe +5f66397542e27adb288e1742720cedbeaba8a2034ebd58397306b2905f4acd2d win-x86/node.lib +93d5377d08c9c5edf71f0c52e801c1ae55ace3e45e107fdac61ec7e04e0e5ff5 win-x86/node_pdb.7z +7601c2ca6c1a64b834af03ac2cb78b8485dc501e8b5b980ac4702ff8d6ad0cfb win-x86/node_pdb.zip diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/normalized-build.json b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/normalized-build.json new file mode 100644 index 00000000..63dd2166 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/normalized-build.json @@ -0,0 +1,9 @@ +{ + "binary_sha256": "4df429454b0289c6df76d7fd95d41394ebc286191809349b2c1e655b6be494e4", + "explicit_make_GOFLAGS": true, + "file_and_directory_times_normalized": true, + "owner_approval": false, + "predecessor_sha256": "4df429454b0289c6df76d7fd95d41394ebc286191809349b2c1e655b6be494e4", + "refinement_same_trial": true, + "source_locks_preserved": true +} diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/normalized-embedded-and-static-backend-result.json b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/normalized-embedded-and-static-backend-result.json new file mode 100644 index 00000000..433c28f7 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/normalized-embedded-and-static-backend-result.json @@ -0,0 +1,16 @@ +{ + "argv": [ + "make", + "GOFLAGS=-trimpath -buildvcs=false -p=4", + "RELEASE_VERSION=11.0.3+nk48.f2788b3f", + "FORGEJO_VERSION=11.0.3+nk48.f2788b3f", + "FORGEJO_VERSION_API=11.0.3+nk48.f2788b3f", + "TAGS=bindata timetzdata sqlite sqlite_unlock_notify", + "go-check", + "generate-backend", + "static-executable" + ], + "phase": "normalized-embedded-and-static-backend", + "returncode": 0, + "seconds": 7.4 +} diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/npm-offline-ci-result.json b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/npm-offline-ci-result.json new file mode 100644 index 00000000..74f34c7e --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/npm-offline-ci-result.json @@ -0,0 +1,13 @@ +{ + "argv": [ + "npm", + "ci", + "--offline", + "--ignore-scripts", + "--no-audit", + "--no-fund" + ], + "phase": "npm-offline-ci", + "returncode": 0, + "seconds": 72.94 +} diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/oci-probe-metadata.json b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/oci-probe-metadata.json new file mode 100644 index 00000000..89c4144f --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/oci-probe-metadata.json @@ -0,0 +1,55 @@ +{ + "buildx.build.provenance": { + "builder": { + "id": "" + }, + "buildType": "https://mobyproject.org/buildkit@v1", + "materials": [ + { + "uri": "pkg:docker/code.forgejo.org/forgejo/forgejo?digest=sha256:41649dff5311664097d29b4d80620403f0b27e7c0eee0b8af79b132a37f3807b\u0026platform=linux%2Famd64", + "digest": { + "sha256": "41649dff5311664097d29b4d80620403f0b27e7c0eee0b8af79b132a37f3807b" + } + } + ], + "invocation": { + "configSource": { + "entryPoint": "Dockerfile" + }, + "parameters": { + "frontend": "dockerfile.v0", + "args": { + "build-arg:SOURCE_DATE_EPOCH": "1752120742", + "force-network-mode": "none", + "no-cache": "" + }, + "locals": [ + { + "name": "context" + }, + { + "name": "dockerfile" + } + ] + }, + "environment": { + "platform": "linux/amd64" + } + } + }, + "buildx.build.ref": "default/default/jm2m1kzz79ebeomnmao91kvkq", + "containerimage.config.digest": "sha256:e9e81da43c5ea6895a95b6304337a40874e3275833be5f21fc6ffc104f79a2af", + "containerimage.descriptor": { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:9d12779f0936b9ccdbde402a9869eaad8bb91ba724d579b6c2983e8e58dabf96", + "size": 2841, + "annotations": { + "org.opencontainers.image.created": "2025-07-10T04:12:22Z" + }, + "platform": { + "architecture": "amd64", + "os": "linux" + } + }, + "containerimage.digest": "sha256:9d12779f0936b9ccdbde402a9869eaad8bb91ba724d579b6c2983e8e58dabf96" +} \ No newline at end of file diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/predecessor-build-driver.py b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/predecessor-build-driver.py new file mode 100644 index 00000000..6159d9ed --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/predecessor-build-driver.py @@ -0,0 +1,22 @@ +import subprocess,pathlib,json,time,os +r=pathlib.Path('/tmp/apps-forgejo-release-build-r1');review=pathlib.Path('/tmp/forgejo-independent-r1');env=json.loads((r/'build-env.json').read_text());src=r/'source' +# Normalise immutable source metadata before webpack and bindata generation. +for root,dirs,files in os.walk(src): + dirs[:]=[d for d in dirs if d not in ('node_modules','.git')] + for name in files: + p=pathlib.Path(root)/name + if not p.is_symlink():os.utime(p,(1752105237,1752105237)) +base=['bwrap','--unshare-net','--ro-bind','/usr','/usr','--ro-bind','/lib','/lib','--ro-bind','/lib64','/lib64','--symlink','usr/bin','/bin','--proc','/proc','--dev','/dev','--tmpfs','/tmp','--bind',str(r),str(r),'--ro-bind',str(review/'go'),str(review/'go'),'--ro-bind',str(review/'gomodcache'),str(review/'gomodcache'),'--chdir',str(src),'--','env','-i']+[k+'='+v for k,v in env.items()] +results=[] +commands=[('verify-go-modules',['go','mod','verify']),('frontend',['make','frontend']),('embedded-and-static-backend',['make','RELEASE_VERSION=11.0.3+nk48.f2788b3f','FORGEJO_VERSION=11.0.3+nk48.f2788b3f','FORGEJO_VERSION_API=11.0.3+nk48.f2788b3f','TAGS=bindata timetzdata sqlite sqlite_unlock_notify','go-check','generate-backend','static-executable'])] +for name,args in commands: + if name=='embedded-and-static-backend': + for folder in ['public','options','templates','modules/migration/schemas']: + for root,dirs,files in os.walk(src/folder): + for n in files: + p=pathlib.Path(root)/n + if not p.is_symlink():os.utime(p,(1752105237,1752105237)) + start=time.time() + with (r/(name+'.log')).open('w') as out:result=subprocess.run(base+args,stdout=out,stderr=subprocess.STDOUT) + item={'phase':name,'argv':args,'returncode':result.returncode,'seconds':round(time.time()-start,2)};results.append(item);(r/'source-build-results.json').write_text(json.dumps(results,indent=2));print(json.dumps(item),flush=True) + if result.returncode:print((r/(name+'.log')).read_text()[-5000:],flush=True);raise SystemExit(result.returncode) diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/predecessor-build-results.json b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/predecessor-build-results.json new file mode 100644 index 00000000..ef2222a9 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/predecessor-build-results.json @@ -0,0 +1,36 @@ +[ + { + "phase": "verify-go-modules", + "argv": [ + "go", + "mod", + "verify" + ], + "returncode": 0, + "seconds": 6.88 + }, + { + "phase": "frontend", + "argv": [ + "make", + "frontend" + ], + "returncode": 0, + "seconds": 103.46 + }, + { + "phase": "embedded-and-static-backend", + "argv": [ + "make", + "RELEASE_VERSION=11.0.3+nk48.f2788b3f", + "FORGEJO_VERSION=11.0.3+nk48.f2788b3f", + "FORGEJO_VERSION_API=11.0.3+nk48.f2788b3f", + "TAGS=bindata timetzdata sqlite sqlite_unlock_notify", + "go-check", + "generate-backend", + "static-executable" + ], + "returncode": 0, + "seconds": 350.05 + } +] \ No newline at end of file diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/predecessor.json b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/predecessor.json new file mode 100644 index 00000000..5861675d --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/predecessor.json @@ -0,0 +1,8 @@ +{ + "binary_sha256": "bc34e12a8794e556764ab2325c31615f6ddbcc7a314aabf106b580e470189911", + "bytes": 111162624, + "path": "/tmp/apps-forgejo-release-build-r1/forgejo-predecessor-no-trimpath", + "source_build_complete": true, + "reproducibility_accepted": false, + "reason": "upstream Make overrides environment GOFLAGS; input directories were not yet normalised" +} diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/prepared.json b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/prepared.json new file mode 100644 index 00000000..ffd7b139 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/prepared.json @@ -0,0 +1,5 @@ +{ + "lock_sha256": "29f329b204a2a5f72084b50d59e8445486ff4799450097c6f3908c74e87a233a", + "module_cache": "/tmp/forgejo-independent-r1/gomodcache", + "public_archives_verified": true +} diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/public-input-reverification.json b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/public-input-reverification.json new file mode 100644 index 00000000..04e42dc4 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/public-input-reverification.json @@ -0,0 +1,9 @@ +{ + "node_archive_reused_existing_download": true, + "official_checksum_url": "https://nodejs.org/dist/v22.15.0/SHASUMS256.txt", + "archive_sha256": "dafe2e8f82cb97de1bd10db9e2ec4c07bbf53389b0799b1e095a918951e78fd4", + "official_checksum_refetched_empty_environment": true, + "proxy_environment_disabled": true, + "HOME_isolated": true, + "private_credential_or_config_access": false +} diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/public-npm-acquisition.json b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/public-npm-acquisition.json new file mode 100644 index 00000000..41ce7114 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/public-npm-acquisition.json @@ -0,0 +1 @@ +{"returncode": 0, "seconds": 46.13} \ No newline at end of file diff --git a/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/recipe-before-directory-time-normalization.py b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/recipe-before-directory-time-normalization.py new file mode 100644 index 00000000..fb3751a5 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-17/apps-forgejo-release-build-r1/evidence/recipe-before-directory-time-normalization.py @@ -0,0 +1,198 @@ +#!/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 + +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','--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)) + +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): + env=base_env(work);binary=str(work/'source/gitea') + env.update(GITEA_WORK_DIR=str(work/'verify-work'),GITEA_CUSTOM=str(work/'verify-custom'),GITEA_APP_INI=str(work/'absent-app.ini')) + (work/'verify-work').mkdir(exist_ok=True) + run(work,'binary-version',[binary,'--version'],env,True) + run(work,'new-cli-help',[binary,'admin','auth','add-oauth','--help'],env,True) + assert '--required-claim-values' in (work/'new-cli-help.log').read_text() + target=work/'embedded-extracted' + if target.exists():raise ValueError('retain prior verification; extraction target already exists') + run(work,'embedded-extract',[binary,'embedded','extract','--include-vendored','--destination',str(target)],env,True) + 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',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_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') + 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\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'} + run(work,'container-build',['docker','buildx','build','--load','--platform',LOCK['platform'],'--pull=false', + '--network=none','--provenance=false','--sbom=false','--iidfile',str(work/'image-id'), + '--metadata-file',str(work/'image-build-metadata.json'),'--build-arg','SOURCE_DATE_EPOCH='+str(LOCK['source_date_epoch']),str(context)],env) + image=(work/'image-id').read_text().strip();assert image.startswith('sha256:') + run(work,'container-save',['docker','image','save','--output',str(work/'image.tar'),image],env) + with tarfile.open(work/'image.tar') as archive: + manifest=json.load(archive.extractfile('manifest.json'));assert len(manifest)==1 + item=manifest[0];config_bytes=archive.extractfile(item['Config']).read() + assert 'sha256:'+hashlib.sha256(config_bytes).hexdigest()==image + config=json.loads(config_bytes) + 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) + assert config['rootfs']['diff_ids'][:-1]==original['rootfs']['diff_ids'] + for key in ['User','Entrypoint','Cmd','Env','WorkingDir','Volumes']: + assert config['config'].get(key)==original['config'].get(key),key + assert config['config']['User']=='1000:1000' + found=False + with tarfile.open(fileobj=archive.extractfile(item['Layers'][-1]),mode='r|*') as layer: + for member in layer: + if member.name.lstrip('./')=='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',{'local_image_id':image,'archive_sha256':digest(work/'image.tar'), + 'all_base_layers_and_rootless_runtime_config_preserved':True,'final_layer_binary_exact':True, + 'registry_publication':False,'deployment_tag':None,'host_or_secret_mounts':False,'privileged_containers':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()