Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 47 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
54 changes: 54 additions & 0 deletions tests/release/RELEASE-ADMISSION.md
Original file line number Diff line number Diff line change
@@ -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.
95 changes: 95 additions & 0 deletions tests/release/observe-release-admission.py
Original file line number Diff line number Diff line change
@@ -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())
64 changes: 64 additions & 0 deletions tests/release/test-release-admission.py
Original file line number Diff line number Diff line change
@@ -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()
Loading