diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 067d5321..ede947e2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,6 +24,10 @@ on: description: "Version to pack+publish (e.g. 0.1.0-preview.1). Required to actually push on a manual run; omit for a pack-only dry run." type: string required: false + admission_only: + description: "Check registry collisions and OIDC authentication without packing or publishing; package creation scope is not exposed by NuGet. Ignores version-tag checkout." + type: boolean + default: false source_run_id: description: "Failed tag release run whose retained package artifact must be resumed." type: string @@ -44,7 +48,7 @@ jobs: name: Verify (locked restore + headless build + test) runs-on: ubuntu-latest # Restrict to the canonical repo: fork tags/dispatch never run this (no fork secret exposure). - if: github.repository == 'FS-GG/FS.GG.Game' && (github.event_name != 'release' || startsWith(github.event.release.tag_name, 'v')) + if: github.repository == 'FS-GG/FS.GG.Game' && (github.event_name != 'release' || startsWith(github.event.release.tag_name, 'v')) && (github.event_name != 'workflow_dispatch' || inputs.admission_only != true) timeout-minutes: 20 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -60,6 +64,7 @@ jobs: run: | tests/release/test-game-0-17-release.sh --source-only tests/release/test-game-0-17-release-selftest.sh + python3 tests/release/test-release-admission.py # COLD, via the shared action (#135 / FS-GG/.github#429) — and this is the job where a warm # restore was worst. There is no `cache: true` here to blame: --locked-mode validates the @@ -81,7 +86,7 @@ jobs: publish-packages: name: Publish FS.GG.Game.* (org feed + nuget.org, release-only) runs-on: ubuntu-latest - if: github.repository == 'FS-GG/FS.GG.Game' && (github.event_name != 'release' || startsWith(github.event.release.tag_name, 'v')) + if: github.repository == 'FS-GG/FS.GG.Game' && (github.event_name != 'release' || startsWith(github.event.release.tag_name, 'v')) && (github.event_name != 'workflow_dispatch' || inputs.admission_only != true) needs: [verify] permissions: actions: read @@ -332,3 +337,43 @@ jobs: path: artifacts/readback.json if-no-files-found: error retention-days: 90 + + admission: + name: Observe registry and OIDC admission (no publication) + if: github.repository == 'FS-GG/FS.GG.Game' && github.event_name == 'workflow_dispatch' && inputs.admission_only == true + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + packages: write # Match the publisher principal; this job makes only registry GET requests. + id-token: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - name: Guard isolated observation mode + shell: bash + env: + SOURCE_RUN_ID: ${{ inputs.source_run_id }} + run: | + test -z "$SOURCE_RUN_ID" + python3 tests/release/test-release-admission.py + - name: Read both registries with the actual repository token + env: + FEED_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: python3 tests/release/observe-release-admission.py --registries artifacts/admission/registries.json + - name: Exchange workflow identity (no package push) + id: admission-login + uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1 + with: + user: ${{ secrets.NUGET_USER || 'Paradigma11' }} + - name: Verify predecessor package authority without publication + env: + NUGET_API_KEY: ${{ steps.admission-login.outputs.NUGET_API_KEY }} + run: python3 tests/release/observe-release-admission.py --authority artifacts/admission/authority.json + - name: Retain sanitized observations + if: always() + uses: actions/upload-artifact@v7 + with: + name: game-release-admission-${{ github.sha }}-${{ github.run_id }} + path: artifacts/admission/*.json + if-no-files-found: warn + retention-days: 30 diff --git a/tests/release/RELEASE-ADMISSION.md b/tests/release/RELEASE-ADMISSION.md new file mode 100644 index 00000000..f7ceb87b --- /dev/null +++ b/tests/release/RELEASE-ADMISSION.md @@ -0,0 +1,54 @@ +# No-publication release observation + +The existing `release.yml` workflow accepts `admission_only=true` on manual +dispatch. This runs a bounded five-minute observation job at the selected source +ref and skips both the full release verifier and the publisher. It does not need +a release tag, pack, restore, or package upload. Leave `source_run_id` empty. + +The job reads both registries using the actual repository workflow token, checks +the known Core 0.16.0 predecessor to distinguish registry authentication from an +unqualified 404, and refuses a visible 0.17.0 collision or unavailable response. +Registry absence means absent to that principal; it does not prove permission +to create a package or reveal unrelated private packages. + +It then uses the same pinned NuGet/login action and account selection as the +publisher. This exchanges the workflow identity for a temporary credential; it +is **not a credential-free dry run**. No credential is written into observations +or printed by the observation script. The action masks its credential output. +Authenticated GET `api/v2/verifykey/{id}` observations check existing package +authority. Cross-host redirects strip credentials. Only sanitized status, +source/run, time and limitation data become artifacts. + +A successful observation is deliberately limited. NuGet's exchange response +contains token type, expiry and key, with no scope readback. Its verify-key route +looks up a package before evaluating its scope; a missing adapter identity +returns 404 without testing new-package creation permission. Therefore the report +always marks adapter creation scope **not exposed and unproven**. It must not be +interpreted as full publication authorization. The account policy roster is a +sign-in-protected UI; the GitHub OIDC exchange does not authorize that UI read. + +These boundaries were checked against [NuGetGallery source at +63b66c98287c61c7e00fe6aefaff86c0d73ba157](https://github.com/NuGet/NuGetGallery/tree/63b66c98287c61c7e00fe6aefaff86c0d73ba157): +`TokenApiController.ApiKeyJson`, `ApiController.VerifyPackageKeyInternalAsync`, +and `UsersController.TrustedPublishing`. [Current NuGet documentation](https://learn.microsoft.com/en-us/nuget/nuget-org/trusted-publishing) +supports new packages through policy scopes and globs. Platform capability is +distinct from the effective account's scope. + +This source change creates no mandatory manual approval or owner-policy-export +gate. It provides a machine route to observe what the existing interfaces expose. +It cannot create missing trust or credentials. The ordinary stable publication +still uses GitHub-first, one retained coherent package set, OIDC public push, +same-byte readbacks and authenticated retained-byte recovery. No release or +successful provider observation is claimed by adding this route. + +Offline qualification: + +```sh +python3 tests/release/test-release-admission.py +tests/release/test-game-0-17-release.sh --source-only +tests/release/test-game-0-17-release-selftest.sh +``` + +The offline controls cover visible collisions, unauthorized/unavailable registry +responses, predecessor authentication, missing-identity scope limits, credential +redirect stripping, and isolation from publication. They do not contact providers. diff --git a/tests/release/observe-release-admission.py b/tests/release/observe-release-admission.py new file mode 100644 index 00000000..823c4ada --- /dev/null +++ b/tests/release/observe-release-admission.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Bounded GET observations; no package or policy writes, no credential readbacks.""" +import argparse +import base64 +from datetime import datetime, timezone +import json +import os +from pathlib import Path +import urllib.error +import urllib.request +from urllib.parse import urlsplit +import xml.etree.ElementTree as ET + +PACKAGES = ("FS.GG.Game.Core", "FS.GG.Game.Render", "FS.GG.Game.Harness", "FS.GG.Game.Physics.Box2D") +ROOT = Path(__file__).resolve().parents[2] + + +class SafeRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, request, response, code, message, headers, newurl): + redirected = super().redirect_request(request, response, code, message, headers, newurl) + if redirected is not None and urlsplit(request.full_url).netloc != urlsplit(newurl).netloc: + redirected.remove_header("Authorization") + redirected.remove_header("X-nuget-apikey") + return redirected + + +def observe(url, headers=None): + request = urllib.request.Request(url, headers={"User-Agent": "FS-GG-Game-release-admission/1", **(headers or {})}, method="GET") + try: + with urllib.request.build_opener(SafeRedirect()).open(request, timeout=15) as response: + # Discard response bodies. Credential-bearing endpoints never become artifacts. + return response.status + except urllib.error.HTTPError as error: + return error.code + except (urllib.error.URLError, TimeoutError): + return 0 + + +def collect(mode, version): + rows = [] + if mode == "registries": + token = os.environ["FEED_TOKEN"] + authorization = "Basic " + base64.b64encode(("fs-gg:" + token).encode()).decode() + headers = {"Authorization": authorization} + sentinel = observe("https://nuget.pkg.github.com/FS-GG/download/fs.gg.game.core/0.16.0/fs.gg.game.core.0.16.0.nupkg", headers) + failed = sentinel != 200 + for package in PACKAGES: + lower = package.lower() + for feed, prefix, auth in ( + ("github", "https://nuget.pkg.github.com/FS-GG/download", headers), + ("nuget", "https://api.nuget.org/v3-flatcontainer", None), + ): + status = observe(f"{prefix}/{lower}/{version}/{lower}.{version}.nupkg", auth) + rows.append({"package": package, "feed": feed, "httpStatus": status, + "observation": "absent-to-this-principal" if status == 404 else "collision" if status == 200 else "unknown"}) + failed |= status != 404 + return {"githubPredecessorReadHttpStatus": sentinel, "packages": rows, + "limits": "GET access does not prove new-package creation authority or visibility of unrelated private packages."}, failed + key = os.environ["NUGET_API_KEY"] + headers = {"X-NuGet-ApiKey": key} + failed = False + for package in PACKAGES: + # No version asks for the existing registration; a missing identity is 404 before scope evaluation. + status = observe(f"https://www.nuget.org/api/v2/verifykey/{package}", headers) + existing = package != "FS.GG.Game.Physics.Box2D" + rows.append({"package": package, "httpStatus": status, + "authority": "existing-package-push" if status == 200 else "unproven"}) + failed |= status != (200 if existing else 404) + return {"oidcExchange": "completed", "packages": rows, + "newAdapterCreationScope": "not-exposed-and-unproven", + "limits": "OIDC exchange returns no scopes. Verify-key 200 covers existing identity; missing identity 404 does not evaluate creation scope. This is not full publication authorization."}, failed + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + modes = parser.add_mutually_exclusive_group(required=True) + modes.add_argument("--registries", type=Path) + modes.add_argument("--authority", type=Path) + args = parser.parse_args() + version = ET.parse(ROOT / "Directory.Build.local.props").findtext(".//Version") + if version != "0.17.0": + raise SystemExit("This observation is scoped to the coherent 0.17.0 release.") + mode = "registries" if args.registries else "authority" + output = args.registries or args.authority + report, failed = collect(mode, version) + report.update(version=version, observedUtc=datetime.now(timezone.utc).isoformat(), + sourceCommit=os.environ.get("GITHUB_SHA"), runId=os.environ.get("GITHUB_RUN_ID")) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(json.dumps(report, indent=2) + "\n") + print(f"{mode}: {'unexpected or unavailable observation' if failed else 'expected limited observations'}; see sanitized artifact") + return 1 if failed else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/release/test-release-admission.py b/tests/release/test-release-admission.py new file mode 100644 index 00000000..aba0957d --- /dev/null +++ b/tests/release/test-release-admission.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Offline checks of admission boundaries and meaningful provider response controls.""" +import importlib.util +from pathlib import Path +import sys +import unittest +from unittest.mock import patch +import urllib.request + +sys.dont_write_bytecode = True + +ROOT = Path(__file__).resolve().parents[2] +spec = importlib.util.spec_from_file_location("admission", Path(__file__).with_name("observe-release-admission.py")) +admission = importlib.util.module_from_spec(spec) +spec.loader.exec_module(admission) + + +class AdmissionTests(unittest.TestCase): + def test_expected_registry_absence_and_authenticated_predecessor(self): + with patch.dict(admission.os.environ, FEED_TOKEN="offline-placeholder"), patch.object(admission, "observe", side_effect=[200] + [404] * 8): + report, failed = admission.collect("registries", "0.17.0") + self.assertFalse(failed) + self.assertEqual(len(report["packages"]), 8) + + def test_registry_collision_unauthorized_and_unavailable_refuse(self): + for status in (200, 401, 403, 0): + with self.subTest(status=status), patch.dict(admission.os.environ, FEED_TOKEN="offline-placeholder"), patch.object(admission, "observe", side_effect=[200, status] + [404] * 7): + self.assertTrue(admission.collect("registries", "0.17.0")[1]) + + def test_predecessor_404_does_not_prove_registry_authentication(self): + with patch.dict(admission.os.environ, FEED_TOKEN="offline-placeholder"), patch.object(admission, "observe", return_value=404): + self.assertTrue(admission.collect("registries", "0.17.0")[1]) + + def test_oidc_success_cannot_prove_adapter_creation_scope(self): + with patch.dict(admission.os.environ, NUGET_API_KEY="offline-placeholder"), patch.object(admission, "observe", side_effect=[200, 200, 200, 404]): + report, failed = admission.collect("authority", "0.17.0") + self.assertFalse(failed) + self.assertEqual(report["newAdapterCreationScope"], "not-exposed-and-unproven") + self.assertEqual(report["packages"][-1]["authority"], "unproven") + + def test_foreign_redirect_does_not_forward_credentials(self): + request = urllib.request.Request("https://www.nuget.org/api/v2/verifykey/example", headers={"X-NuGet-ApiKey": "offline-placeholder", "Authorization": "offline-placeholder"}) + redirected = admission.SafeRedirect().redirect_request(request, None, 302, "Found", {}, "https://other.example/result") + self.assertNotIn("Authorization", redirected.headers) + self.assertNotIn("X-nuget-apikey", redirected.headers) + + def test_existing_package_authority_failure_refuses(self): + with patch.dict(admission.os.environ, NUGET_API_KEY="offline-placeholder"), patch.object(admission, "observe", side_effect=[403, 200, 200, 404]): + self.assertTrue(admission.collect("authority", "0.17.0")[1]) + + def test_workflow_observation_cannot_enter_publisher(self): + text = (ROOT / ".github/workflows/release.yml").read_text() + excluded = "&& (github.event_name != 'workflow_dispatch' || inputs.admission_only != true)" + self.assertEqual(text.count(excluded), 2) + job = text.split("\n admission:\n", 1)[1] + self.assertIn("github.event_name == 'workflow_dispatch' && inputs.admission_only == true", job) + for effect in ("dotnet", "nuget push", "source_run_id }}'", "GenerateTrustedPublisherPolicy"): + self.assertNotIn(effect, job) + self.assertIn('test -z "$SOURCE_RUN_ID"', job) + self.assertNotIn("ref:", job) + + +if __name__ == "__main__": + unittest.main()