From cafc91060b788d5291e068b351d0eedfb9762d3c Mon Sep 17 00:00:00 2001 From: Alex M <9vy6rqx2p8@privaterelay.appleid.com> Date: Sat, 10 Oct 2026 13:30:52 +0000 Subject: [PATCH] ci: support protected solo-maintainer release approval Bind release dispatches, reruns and environment approval to the sole maintainer account while retaining artifact and publication gates. Assisted-by: OpenAI Codex --- .github/workflows/github-release.yml | 6 +- .github/workflows/release-validation.yml | 6 +- docs/development/ci-release.md | 35 ++++++++- tests/test_release_workflows.py | 92 ++++++++++++++++++++++-- tools/check_workflows.py | 6 +- tools/github_release.py | 7 +- tools/release_gate.py | 33 +++++++-- 7 files changed, 160 insertions(+), 25 deletions(-) diff --git a/.github/workflows/github-release.yml b/.github/workflows/github-release.yml index 879e23b..ebf0dd4 100644 --- a/.github/workflows/github-release.yml +++ b/.github/workflows/github-release.yml @@ -26,7 +26,7 @@ concurrency: cancel-in-progress: false jobs: validate: - if: github.repository == 'AlexMarinescu/pyGameMath' && github.ref == 'refs/heads/master' && github.event_name == 'workflow_dispatch' + if: github.repository == 'AlexMarinescu/pyGameMath' && github.ref == 'refs/heads/master' && github.event_name == 'workflow_dispatch' && github.actor == 'AlexMarinescu' && github.triggering_actor == 'AlexMarinescu' runs-on: ubuntu-latest timeout-minutes: 10 permissions: @@ -54,7 +54,7 @@ jobs: VALIDATION_RUN: ${{ inputs.validation_run_id }} GITHUB_TOKEN: ${{ github.token }} publish: - if: inputs.mode == 'publish' && vars.GEM_RELEASE_ENABLED == 'true' && inputs.owner_authorization == 'publish v1.0.0' + if: inputs.mode == 'publish' && vars.GEM_RELEASE_ENABLED == 'true' && inputs.owner_authorization == 'publish v1.0.0' && github.actor == 'AlexMarinescu' && github.triggering_actor == 'AlexMarinescu' needs: validate environment: gem-release-review runs-on: ubuntu-latest @@ -77,7 +77,7 @@ jobs: github-token: ${{ github.token }} path: ${{ runner.temp }}/gem-candidate digest-mismatch: error - - name: Publish only after explicit owner authorization and protected approval + - name: Publish only after explicit owner authorization and protected maintainer self-approval run: python tools/github_release.py --directory "${{ runner.temp }}/gem-candidate" --commit "$CANDIDATE_COMMIT" --run-id "$VALIDATION_RUN" --mode publish --output github-release-publication.json env: CANDIDATE_COMMIT: ${{ inputs.commit_sha }} diff --git a/.github/workflows/release-validation.yml b/.github/workflows/release-validation.yml index 7cf4a22..b7ce130 100644 --- a/.github/workflows/release-validation.yml +++ b/.github/workflows/release-validation.yml @@ -17,7 +17,7 @@ on: default: 'v1.0.0' required: true mode: - description: Validation only, or protected human review; neither publishes + description: Validation only, or protected maintainer self-approval; neither publishes type: choice options: [validate, review] default: validate @@ -28,7 +28,7 @@ concurrency: cancel-in-progress: false jobs: preflight: - if: github.repository == 'AlexMarinescu/pyGameMath' && github.ref == 'refs/heads/master' && github.event_name == 'workflow_dispatch' + if: github.repository == 'AlexMarinescu/pyGameMath' && github.ref == 'refs/heads/master' && github.event_name == 'workflow_dispatch' && github.actor == 'AlexMarinescu' && github.triggering_actor == 'AlexMarinescu' runs-on: ubuntu-latest timeout-minutes: 5 permissions: @@ -97,5 +97,5 @@ jobs: timeout-minutes: 5 environment: gem-release-review steps: - - name: Record protected human review; publication remains disabled + - name: Record protected maintainer self-approval; publication remains disabled run: echo "Candidate validation approved. PyPI authority remains unverified; no publication is enabled." diff --git a/docs/development/ci-release.md b/docs/development/ci-release.md index 319e7a4..d7be50a 100644 --- a/docs/development/ci-release.md +++ b/docs/development/ci-release.md @@ -41,14 +41,15 @@ same commit. Forks and other branches cannot enter candidate validation. `validate` runs the complete matrix and verifies the resulting candidate bundle. `review` additionally requires the `gem-release-review` environment to have: -- Required human reviewers with self-review prevented. +- Required reviewer: `AlexMarinescu` alone (GitHub user ID `955100`). +- **Prevent self-review disabled**, so the sole maintainer can approve their own run. - Administrative approval bypass disabled. - Deployment limited to protected branches, with master protected server-side. Configure these protections in repository settings before using review mode. The preflight reads and validates the server configuration; a missing or inaccessible protection fails closed. It does not create an environment or -change repository settings. Human approval occurs only after the matrix and +change repository settings. Maintainer self-approval occurs only after the matrix and artifact integrity checks pass. Approval still enables **no publication**. Candidate artifacts contain the wheel, sdist, `SHA256SUMS`, installation instructions @@ -111,7 +112,7 @@ Preserve official assets durably once released. Phase 5B tests preparation only. Phase 5C performs final validation. In Phase 5D, separate explicit owner authorization must precede setting repository variable `GEM_RELEASE_ENABLED=true` and dispatching mode `publish` with the exact -phrase `publish v1.0.0`. The protected environment requires human approval. +phrase `publish v1.0.0`. The protected environment requires explicit maintainer self-approval. Only this publish job has contents-write permission. It rechecks provenance and protections before creating the tag/release at the exact reviewed commit and attaching the original archives, checksums and installation instructions. @@ -122,3 +123,31 @@ release. If a future authorized upload fails partway, inspect the existing release/tag and assets before any recovery; this workflow refuses overwrites. Never silently replace official artifacts. PyPI remains separately disabled, regardless of GitHub publication approval. + + +## Solo-maintainer release approval + +Release dispatches and reruns are restricted to `AlexMarinescu` (GitHub user ID +`955100`). The workflow checks both initiating and triggering actor names; +preflight and the publisher also verify the event sender's immutable account ID. +Candidate validation-run provenance must identify that same account for both +actors. Missing or mismatched identity fails closed in validation and publication. +A changed maintainer account requires a reviewed code change, not a workflow input. + +In **Settings → Environments → gem-release-review**, set the required reviewer to +`AlexMarinescu` alone, turn **Prevent self-review off**, disable administrator +bypass, and retain deployment restrictions to protected branches. Master must +remain protected. These settings are checked by the workflow; this change does +not update repository settings automatically. No second account is required. + +The approval is a separate deliberate action after candidate validation, not an +independent-person review. The owner reviews the exact commit, successful manual +validation run, candidate manifest and checksum listing before approving the job. +Keep the release-enable variable unset/false until separately authorizing +publication. Mode `publish`, the exact phrase `publish v1.0.0`, the enable switch, +master-only dispatch, successful full CI, matching artifact hashes, protected +self-approval and tag/release checks must all pass. A dry run still cannot publish. + +Account security (including GitHub two-factor authentication) remains essential: +this model protects against accidental publication and other dispatch identities, +not compromise of the sole maintainer account. PyPI publishing stays disabled. diff --git a/tests/test_release_workflows.py b/tests/test_release_workflows.py index c86a07b..0a88d69 100644 --- a/tests/test_release_workflows.py +++ b/tests/test_release_workflows.py @@ -55,8 +55,9 @@ def test_reject_unapproved_candidate(commit, version, tag, mode): def protected_environment(): return dict(name=gate.ENVIRONMENT, can_admins_bypass=False, - protection_rules=[dict(type='required_reviewers', reviewers=[{'id': 1}], - prevent_self_review=True)], + protection_rules=[dict(type='required_reviewers', + reviewers=[{'type':'User', 'reviewer':{'id':gate.MAINTAINER_ID, 'login':gate.MAINTAINER_LOGIN}}], + prevent_self_review=False)], deployment_branch_policy=dict(protected_branches=True, custom_branch_policies=False)) @@ -67,7 +68,7 @@ def test_review_requires_server_side_protection(defect): branch = {'protected': True} assert gate.verify_review_configuration(environment, branch)['required_reviewers'] if defect == 'reviewers': environment['protection_rules'][0]['reviewers'] = [] - if defect == 'self_review': environment['protection_rules'][0]['prevent_self_review'] = False + if defect == 'self_review': environment['protection_rules'][0]['prevent_self_review'] = True if defect == 'bypass': environment['can_admins_bypass'] = True if defect == 'branches': environment['deployment_branch_policy']['protected_branches'] = False if defect == 'protection': branch['protected'] = False @@ -156,7 +157,8 @@ def test_github_publication_requires_canonical_context(): ('head_branch', 'topic'), ('conclusion', 'failure'), ('status', 'in_progress'), ('path', 'other.yml')]) def test_github_release_requires_exact_successful_candidate_run(field, value): - run = dict(id=123, repository={'full_name': gate.REPOSITORY}, + run = dict(id=123, actor={'login':gate.MAINTAINER_LOGIN,'id':gate.MAINTAINER_ID}, + triggering_actor={'login':gate.MAINTAINER_LOGIN,'id':gate.MAINTAINER_ID}, repository={'full_name': gate.REPOSITORY}, path='.github/workflows/release-validation.yml', event='workflow_dispatch', head_branch='master', head_sha=SHA, status='completed', conclusion='success') release.validate_run(run, SHA, '123') @@ -175,13 +177,16 @@ def test_release_execution_uses_exact_assets_and_no_dry_run_mutation(tmp_path, m import json import urllib.error event = tmp_path / 'event.json' - event.write_text(json.dumps({'repository': {'fork': False}})) + event.write_text(json.dumps({'repository': {'fork': False}, + 'sender': {'login':gate.MAINTAINER_LOGIN,'id':gate.MAINTAINER_ID}})) for key, value in {'GITHUB_EVENT_PATH': str(event), 'GITHUB_EVENT_NAME': 'workflow_dispatch', 'GITHUB_REPOSITORY': gate.REPOSITORY, 'GITHUB_REF': 'refs/heads/master', - 'GITHUB_SHA': SHA, 'RELEASE_ENABLED': 'true', + 'GITHUB_SHA': SHA, 'GITHUB_ACTOR':gate.MAINTAINER_LOGIN, + 'GITHUB_TRIGGERING_ACTOR':gate.MAINTAINER_LOGIN, 'RELEASE_ENABLED': 'true', 'OWNER_AUTHORIZATION': 'publish v1.0.0'}.items(): monkeypatch.setenv(key, value) - run = dict(id=123, repository={'full_name': gate.REPOSITORY}, + run = dict(id=123, actor={'login':gate.MAINTAINER_LOGIN,'id':gate.MAINTAINER_ID}, + triggering_actor={'login':gate.MAINTAINER_LOGIN,'id':gate.MAINTAINER_ID}, repository={'full_name': gate.REPOSITORY}, path='.github/workflows/release-validation.yml', event='workflow_dispatch', head_branch='master', head_sha=SHA, status='completed', conclusion='success') def get(path): @@ -289,3 +294,76 @@ def test_canonical_artifact_matrix_cannot_bypass_validation(change): else: job['strategy']['matrix']['python'] = ['3.12'] with pytest.raises(ValueError, match='canonical'): workflows.validate(data) + + +@pytest.mark.parametrize('field,value', [('actor','other'), ('triggering_actor','other'), + ('actor',None), ('triggering_actor',None)]) +def test_solo_dispatch_rejects_other_or_missing_actor(field, value): + context = dict(CONTEXT, actor=gate.MAINTAINER_LOGIN, triggering_actor=gate.MAINTAINER_LOGIN) + sender = {'login':gate.MAINTAINER_LOGIN,'id':gate.MAINTAINER_ID} + assert gate.verify_dispatch_actor(context,sender)['maintainer_id']==955100 + context[field]=value + with pytest.raises(ValueError): gate.verify_dispatch_actor(context,sender) + + +@pytest.mark.parametrize('sender', [{}, {'login':'other','id':955100}, + {'login':'AlexMarinescu','id':123}, {'login':'AlexMarinescu','id':'955100'}]) +def test_solo_dispatch_checks_immutable_account_identity(sender): + context=dict(CONTEXT,actor=gate.MAINTAINER_LOGIN,triggering_actor=gate.MAINTAINER_LOGIN) + with pytest.raises(ValueError): gate.verify_dispatch_actor(context,sender) + + +@pytest.mark.parametrize('defect', ['team', 'other', 'extra', 'missing', 'name']) +def test_environment_accepts_only_sole_maintainer_reviewer(defect): + environment=protected_environment() + result=gate.verify_review_configuration(environment,{'protected':True}) + assert result['prevent_self_review'] is False + reviewers=environment['protection_rules'][0]['reviewers'] + if defect=='team': reviewers[0]['type']='Team' + elif defect=='other': reviewers[0]['reviewer']['id']=123 + elif defect=='extra': reviewers.append(copy.deepcopy(reviewers[0])) + elif defect=='missing': environment['protection_rules']=[] + else: environment['name']='other-environment' + with pytest.raises(ValueError): gate.verify_review_configuration(environment,{'protected':True}) + + +@pytest.mark.parametrize('field', ['actor','triggering_actor']) +def test_candidate_run_rejects_other_dispatch_or_rerun_account(field): + run=dict(id=123,actor={'login':gate.MAINTAINER_LOGIN,'id':gate.MAINTAINER_ID}, + triggering_actor={'login':gate.MAINTAINER_LOGIN,'id':gate.MAINTAINER_ID}, + repository={'full_name':gate.REPOSITORY},path='.github/workflows/release-validation.yml', + event='workflow_dispatch',head_branch='master',head_sha=SHA,status='completed',conclusion='success') + release.validate_run(run,SHA,'123') + run[field]['id']=123 + with pytest.raises(ValueError): release.validate_run(run,SHA,'123') + + +@pytest.mark.parametrize('workflow,job', [('release-validation.yml','preflight'), + ('github-release.yml','validate'), ('github-release.yml','publish')]) +def test_owner_workflow_guard_cannot_be_removed(workflow,job): + data=workflows.load_workflows() + data[workflow]['jobs'][job]['if']=data[workflow]['jobs'][job]['if'].replace( + " && github.triggering_actor == 'AlexMarinescu'", '') + with pytest.raises(ValueError): workflows.validate(data) + + +@pytest.mark.parametrize('defect', ['sender', 'actor', 'rerun']) +def test_unauthorized_publish_stops_before_network_or_mutation(tmp_path,monkeypatch,defect): + import json + sender={'login':gate.MAINTAINER_LOGIN,'id':gate.MAINTAINER_ID} + actors={'GITHUB_ACTOR':gate.MAINTAINER_LOGIN,'GITHUB_TRIGGERING_ACTOR':gate.MAINTAINER_LOGIN} + if defect=='sender': sender['id']=123 + elif defect=='actor': actors['GITHUB_ACTOR']='other' + else: actors['GITHUB_TRIGGERING_ACTOR']='other' + event=tmp_path/'event.json' + event.write_text(json.dumps({'repository':{'fork':False},'sender':sender})) + for key,value in dict(actors,GITHUB_EVENT_PATH=str(event),GITHUB_EVENT_NAME='workflow_dispatch', + GITHUB_REPOSITORY=gate.REPOSITORY,GITHUB_REF='refs/heads/master',GITHUB_SHA=SHA, + RELEASE_ENABLED='true',OWNER_AUTHORIZATION='publish v1.0.0').items():monkeypatch.setenv(key,value) + def forbidden(*args,**kwargs):raise AssertionError('unauthorized dispatch reached external operation') + monkeypatch.setattr(release.gate,'github_get',forbidden) + monkeypatch.setattr(release.subprocess,'run',forbidden) + monkeypatch.setattr('sys.argv',['github_release.py','--directory',str(tmp_path),'--commit',SHA, + '--run-id','123','--mode','publish','--output',str(tmp_path/'result.json')]) + with pytest.raises(ValueError):release.main() + assert not (tmp_path/'result.json').exists() diff --git a/tools/check_workflows.py b/tools/check_workflows.py index 794bf4b..125692a 100644 --- a/tools/check_workflows.py +++ b/tools/check_workflows.py @@ -108,7 +108,7 @@ def validate(workflows): require(inputs['mode']['options']==['validate','review'] and inputs['mode']['default']=='validate', 'publication mode is forbidden') require(inputs['version']['default']=='1.0.0' and inputs['tag']['default']=='v1.0.0','exact version/tag defaults required') - guard="github.repository == 'AlexMarinescu/pyGameMath' && github.ref == 'refs/heads/master' && github.event_name == 'workflow_dispatch'" + guard="github.repository == 'AlexMarinescu/pyGameMath' && github.ref == 'refs/heads/master' && github.event_name == 'workflow_dispatch' && github.actor == 'AlexMarinescu' && github.triggering_actor == 'AlexMarinescu'" jobs=release['jobs'];require(jobs['preflight']['if']==guard,'release source/event guard changed') require(jobs['validation']['needs']=='preflight' and jobs['validation']['uses']=='./.github/workflows/package-validation.yml', 'release must reuse packaging checks after preflight') @@ -121,8 +121,8 @@ def validate(workflows): gjobs=github_release['jobs'] require(gjobs['validate']['if']==guard,'GitHub release source/event guard changed') require(gjobs['publish']['needs']=='validate' and gjobs['publish']['environment']=='gem-release-review', - 'GitHub release protected approval bypass') - require(gjobs['publish']['if']=="inputs.mode == 'publish' && vars.GEM_RELEASE_ENABLED == 'true' && inputs.owner_authorization == 'publish v1.0.0'", + 'GitHub release protected maintainer self-approval bypass') + require(gjobs['publish']['if']=="inputs.mode == 'publish' && vars.GEM_RELEASE_ENABLED == 'true' && inputs.owner_authorization == 'publish v1.0.0' && github.actor == 'AlexMarinescu' && github.triggering_actor == 'AlexMarinescu'", 'GitHub publication requires explicit enablement and authorization') require(github_release['on']['workflow_dispatch']['inputs']['mode']['default']=='validate', 'GitHub release must default to dry validation') diff --git a/tools/github_release.py b/tools/github_release.py index e4e954f..e63d1b4 100644 --- a/tools/github_release.py +++ b/tools/github_release.py @@ -14,6 +14,8 @@ def validate_run(run, commit, run_id): + gate.verify_maintainer_identity(run.get('actor', {})) + gate.verify_maintainer_identity(run.get('triggering_actor', {})) gate.require(str(run.get('id')) == str(run_id), 'validation run ID mismatch') gate.require(run.get('repository', {}).get('full_name') == gate.REPOSITORY, 'candidate run must belong to canonical repository') @@ -59,8 +61,9 @@ def main(): args = parser.parse_args() event = json.loads(Path(os.environ['GITHUB_EVENT_PATH']).read_text(encoding='utf-8')) context = {name: os.environ.get('GITHUB_' + name.upper()) - for name in ('event_name', 'repository', 'ref', 'sha')} + for name in ('event_name', 'repository', 'ref', 'sha', 'actor', 'triggering_actor')} context['fork'] = event.get('repository', {}).get('fork', True) + authorization = gate.verify_dispatch_actor(context, event.get('sender', {})) validate_publication(context, os.environ.get('RELEASE_ENABLED'), os.environ.get('OWNER_AUTHORIZATION'), args.mode) gate.require(args.commit == context['sha'], 'release must match final dispatched master commit') @@ -71,7 +74,7 @@ def main(): gate.github_get('environments/' + gate.ENVIRONMENT), gate.github_get('branches/master')) gate.preflight(context, args.commit, gate.VERSION, gate.TAG, 'review') result = gate.verify_artifacts(args.directory, args.commit, gate.TAG) - result.update(mode=args.mode, validation_run_id=args.run_id, protection=protection, + result.update(authorization=authorization, mode=args.mode, validation_run_id=args.run_id, protection=protection, github_publication_enabled=args.mode == 'publish', pypi_publication_enabled=False) # A release is immutable by policy: never overwrite assets or reuse a published release. try: diff --git a/tools/release_gate.py b/tools/release_gate.py index 823dd00..4c0632e 100644 --- a/tools/release_gate.py +++ b/tools/release_gate.py @@ -15,6 +15,8 @@ VERSION='1.0.0' TAG='v1.0.0' ENVIRONMENT='gem-release-review' +MAINTAINER_LOGIN='AlexMarinescu' +MAINTAINER_ID=955100 FILES={'gem-1.0.0-py3-none-any.whl','gem-1.0.0.tar.gz'} @@ -34,18 +36,39 @@ def validate_context(context,commit,version,tag,mode): 'publication_enabled':False,'ownership_verified':False} +def verify_maintainer_identity(actor): + require(isinstance(actor,dict) and actor.get('login')==MAINTAINER_LOGIN and + type(actor.get('id')) is int and actor['id']==MAINTAINER_ID, + 'release requires the configured maintainer account') + + +def verify_dispatch_actor(context,sender): + verify_maintainer_identity(sender) + require(context.get('actor')==MAINTAINER_LOGIN and + context.get('triggering_actor')==MAINTAINER_LOGIN, + 'dispatch and rerun must both belong to the configured maintainer') + return {'maintainer_login':MAINTAINER_LOGIN,'maintainer_id':MAINTAINER_ID, + 'dispatch_actor':context['actor'],'triggering_actor':context['triggering_actor']} + + def verify_review_configuration(environment,branch): require(branch.get('protected') is True,'master must have server-side branch protection') require(environment.get('name')==ENVIRONMENT,'release review environment missing') rules=environment.get('protection_rules',[]) reviewers=[r for r in rules if r.get('type')=='required_reviewers'] - require(len(reviewers)==1 and bool(reviewers[0].get('reviewers')),'environment must require reviewers') - require(reviewers[0].get('prevent_self_review') is True,'self-review must be prevented') + require(len(reviewers)==1 and len(reviewers[0].get('reviewers',[]))==1, + 'environment must require exactly the sole maintainer reviewer') + reviewer=reviewers[0]['reviewers'][0] + require(reviewer.get('type')=='User','release reviewer must be the maintainer user, not a team') + verify_maintainer_identity(reviewer.get('reviewer',{})) + require(reviewers[0].get('prevent_self_review') is False, + 'solo-maintainer approval requires self-review to be allowed') require(environment.get('can_admins_bypass') is False,'administrative approval bypass must be disabled') policy=environment.get('deployment_branch_policy') or {} require(policy.get('protected_branches') is True and policy.get('custom_branch_policies') is False, 'environment must restrict deployments to protected branches') - return {'environment':ENVIRONMENT,'required_reviewers':True,'prevent_self_review':True, + return {'environment':ENVIRONMENT,'required_reviewers':True,'prevent_self_review':False, + 'approval_model':'sole maintainer self-approval','maintainer_id':MAINTAINER_ID, 'admin_bypass':False,'protected_branches_only':True} @@ -118,10 +141,12 @@ def main(): args=parser.parse_args() if args.command=='preflight': event=json.loads(Path(os.environ['GITHUB_EVENT_PATH']).read_text(encoding='utf-8')) - context={name:os.environ.get('GITHUB_'+name.upper()) for name in ('event_name','repository','ref','sha')} + context={name:os.environ.get('GITHUB_'+name.upper()) for name in ('event_name','repository','ref','sha','actor','triggering_actor')} context['fork']=event.get('repository',{}).get('fork',True) + actor=verify_dispatch_actor(context,event.get('sender',{})) report=preflight(context,os.environ.get('INPUT_COMMIT'),os.environ.get('INPUT_VERSION'), os.environ.get('INPUT_TAG'),os.environ.get('INPUT_MODE')) + report['authorization']=actor if os.environ.get('GITHUB_OUTPUT'): with open(os.environ['GITHUB_OUTPUT'],'a',encoding='utf-8') as output: for name in ('source_commit','version','tag','mode'):output.write(name+'='+report[name]+'\n')