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
6 changes: 3 additions & 3 deletions .github/workflows/github-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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 }}
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/release-validation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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."
35 changes: 32 additions & 3 deletions docs/development/ci-release.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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.
92 changes: 85 additions & 7 deletions tests/test_release_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand All @@ -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
Expand Down Expand Up @@ -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')
Expand All @@ -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):
Expand Down Expand Up @@ -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()
6 changes: 3 additions & 3 deletions tools/check_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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')
Expand Down
7 changes: 5 additions & 2 deletions tools/github_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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')
Expand All @@ -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:
Expand Down
Loading
Loading