Merge pull request #110 from nanox333/codex/release-contributor-refresh

Release: refresh contributor credits automatically before builds
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-01 01:00:55 -04:00
commit 2ce6aa59c7
5 files changed
+335

No files matched your search

@@ -0,0 +1,50 @@
name: Refresh release contributors
on:
workflow_call:
outputs:
artifact-name:
description: Contributor snapshot to pass to the release workflow
value: ${{ jobs.refresh.outputs.artifact-name }}
permissions:
contents: read
pull-requests: read
jobs:
refresh:
runs-on: ubuntu-24.04
timeout-minutes: 15
outputs:
artifact-name: contributor-snapshot-${{ github.run_id }}-${{ github.run_attempt }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
- name: Refresh contributor credits for this release commit
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
python3 scripts/refresh-contributors.py --repo "$GITHUB_REPOSITORY"
python3 - <<'PY'
import json
import os
from pathlib import Path
credits = json.loads(Path("contributors/contributors.json").read_text())
if credits.get("head") != os.environ["GITHUB_SHA"]:
raise SystemExit("Contributor snapshot does not match the release commit")
if credits.get("repo") != os.environ["GITHUB_REPOSITORY"]:
raise SystemExit("Contributor snapshot does not match the release repository")
if not credits.get("contributors"):
raise SystemExit("Contributor snapshot is empty")
PY
- name: Save the refreshed snapshot for all release builds
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: contributor-snapshot-${{ github.run_id }}-${{ github.run_attempt }}
path: contributors/contributors.json
if-no-files-found: error
retention-days: 7
+47
View File
@@ -27,6 +27,11 @@ on:
type: string
required: false
default: ""
contributors-artifact:
description: "Optional contributor snapshot artifact from refresh-contributors.yml in this workflow run; installed before every source build"
type: string
required: false
default: ""
has-web:
description: "Whether to build the WebAssembly (wasm) release"
type: boolean
@@ -264,6 +269,12 @@ jobs:
exit 1
fi
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Load refreshed contributor credits
if: inputs.contributors-artifact != ''
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: ${{ inputs.contributors-artifact }}
path: contributors
- name: Check out craft-fonts
if: inputs.craft-fonts-ref != ''
uses: storytold/craft-common-actions/actions/checkout-craft-fonts@main
@@ -414,6 +425,12 @@ jobs:
exit 1
fi
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Load refreshed contributor credits
if: inputs.contributors-artifact != ''
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: ${{ inputs.contributors-artifact }}
path: contributors
- name: Check out craft-fonts
if: inputs.craft-fonts-ref != ''
uses: storytold/craft-common-actions/actions/checkout-craft-fonts@main
@@ -519,6 +536,12 @@ jobs:
INPUT_VERSION: ${{ inputs.version }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Load refreshed contributor credits
if: inputs.contributors-artifact != ''
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: ${{ inputs.contributors-artifact }}
path: contributors
- name: Check out craft-fonts
if: inputs.craft-fonts-ref != ''
uses: storytold/craft-common-actions/actions/checkout-craft-fonts@main
@@ -798,6 +821,12 @@ jobs:
;;
esac
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Load refreshed contributor credits
if: inputs.contributors-artifact != ''
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: ${{ inputs.contributors-artifact }}
path: contributors
- name: Check out craft-fonts
if: inputs.craft-fonts-ref != ''
uses: storytold/craft-common-actions/actions/checkout-craft-fonts@main
@@ -1041,6 +1070,12 @@ jobs:
INPUT_VERSION: ${{ inputs.version }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Load refreshed contributor credits
if: inputs.contributors-artifact != ''
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: ${{ inputs.contributors-artifact }}
path: contributors
- uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # v1
with:
toolchain: stable
@@ -1139,6 +1174,12 @@ jobs:
INPUT_VERSION: ${{ inputs.version }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Load refreshed contributor credits
if: inputs.contributors-artifact != ''
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: ${{ inputs.contributors-artifact }}
path: contributors
- name: Check out craft-fonts
if: inputs.craft-fonts-ref != ''
uses: storytold/craft-common-actions/actions/checkout-craft-fonts@main
@@ -1210,6 +1251,12 @@ jobs:
FIGCRAFT_BUILD_SHA: ${{ github.sha }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Load refreshed contributor credits
if: inputs.contributors-artifact != ''
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: ${{ inputs.contributors-artifact }}
path: contributors
- name: Check out craft-i18n
uses: storytold/craft-common-actions/actions/checkout-craft-i18n@main
- uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # v1
+2
View File
@@ -5,6 +5,7 @@ on:
paths:
- .github/workflows/freebsd.yml
- .github/workflows/release.yml
- .github/workflows/refresh-contributors.yml
- .github/workflows/gate.yml
- actions/freebsd-cargo-cache/**
- actions/checkout-craft-i18n/**
@@ -17,6 +18,7 @@ on:
paths:
- .github/workflows/freebsd.yml
- .github/workflows/release.yml
- .github/workflows/refresh-contributors.yml
- .github/workflows/gate.yml
- actions/freebsd-cargo-cache/**
- actions/checkout-craft-i18n/**
+42
View File
@@ -65,6 +65,48 @@ jobs:
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
```
#### Automatic contributor credits (opt-in)
Apps with `scripts/refresh-contributors.py` can refresh bundled credits once before a release.
Grant the caller workflow `contents: write` for release publishing, and keep the preparatory
`credits` job read-only with its job-level override. A called workflow cannot increase the
token permissions granted by its caller; see [reusable-workflow permissions](https://docs.github.com/en/actions/reference/workflows-and-actions/reusing-workflow-configurations#supported-keywords-for-jobs-that-call-a-reusable-workflow).
Call the preparatory workflow, then pass its output to the release workflow:
```yaml
permissions:
contents: write
jobs:
credits:
permissions:
contents: read
pull-requests: read
uses: storytold/craft-common-actions/.github/workflows/refresh-contributors.yml@main
release:
needs: credits
permissions:
contents: write
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
with:
app-name: effectcraft
app-title: EffectCraft
contributors-artifact: ${{ needs.credits.outputs.artifact-name }}
# Include the app's existing signing secrets here.
```
The preparatory workflow checks out full history, runs the app's script with `--repo` set to
`github.repository` and a read-only `GH_TOKEN`, and validates that the resulting nonempty
`contributors/contributors.json` belongs to `github.sha`. It uploads that file only after a
successful refresh. The intermediate `contributor-snapshot-*` artifact stays outside the
`release-*` collection used for release assets and checksums, including on reruns.
Every source-build job installs the same artifact after checkout, including
FreeBSD before copying the workspace to its VM. Flatpak and Arch inherit the refreshed credits
from the Linux binaries. The generated snapshot is not committed or pushed back to the app.
An unsuccessful refresh or missing artifact blocks the release. Existing callers that omit
`contributors-artifact` keep their current behavior, permissions, and build paths.
List every signing secret explicitly. The signing jobs run in the caller repo's `release` environment and read the values from there, but only for the names listed here: an unlisted name is empty. On the `release` branch the macOS and Windows jobs fail when their signing secrets are empty, rather than shipping unsigned builds.
macOS and Windows packages are signed by the app's own packaging scripts. `release.yml` does not request `id-token` or `attestations`, so existing callers keep building as they do now.
+194
View File
@@ -0,0 +1,194 @@
"""Exercise release credit refresh failure handling and artifact distribution."""
import fnmatch
import hashlib
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
import yaml
ROOT = Path(__file__).resolve().parents[1]
def load(name):
"""Read workflow scalars as strings, including GitHub's `on` key."""
return yaml.load((ROOT / '.github/workflows' / name).read_text(), Loader=yaml.BaseLoader)
class ContributorRefreshTests(unittest.TestCase):
def run_refresh(self, mode):
"""Execute the real validation step with a fixture refresh script."""
workflow = load('refresh-contributors.yml')
step = next(s for s in workflow['jobs']['refresh']['steps'] if 'run' in s)
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / 'scripts').mkdir()
(root / 'contributors').mkdir()
(root / 'scripts/refresh-contributors.py').write_text('''import json, os, sys
from pathlib import Path
assert sys.argv[1:] == ['--repo', 'owner/app']
mode = os.environ['TEST_MODE']
if mode == 'failed':
raise SystemExit(23)
credits = dict(head=os.environ['GITHUB_SHA'], repo='owner/app', contributors=[dict(login='person')])
if mode == 'stale':
credits['head'] = 'old-commit'
if mode == 'wrong-repo':
credits['repo'] = 'other/app'
if mode == 'empty':
credits['contributors'] = []
Path('contributors/contributors.json').write_text(json.dumps(credits))
''')
result = subprocess.run(['bash', '-c', step['run']], cwd=root,
env=dict(os.environ, GITHUB_REPOSITORY='owner/app',
GITHUB_SHA='release-commit', TEST_MODE=mode),
text=True, capture_output=True, timeout=10)
snapshot = root / 'contributors/contributors.json'
return result, json.loads(snapshot.read_text()) if snapshot.exists() else None
def test_refresh_runs_against_the_release_repository_and_commit(self):
"""Accept credits generated for the caller's exact repository and head."""
result, snapshot = self.run_refresh('valid')
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(snapshot['head'], 'release-commit')
def test_failed_refresh_stops_before_validation_or_upload(self):
"""Propagate refresh failures before any snapshot can be uploaded."""
result, snapshot = self.run_refresh('failed')
self.assertEqual(result.returncode, 23)
self.assertIsNone(snapshot)
def test_rejects_stale_wrong_repository_and_empty_snapshots(self):
"""Reject unusable credits even when the refresh command succeeds."""
for mode in ('stale', 'wrong-repo', 'empty'):
with self.subTest(mode=mode):
result, _ = self.run_refresh(mode)
self.assertNotEqual(result.returncode, 0)
self.assertIn('Contributor snapshot', result.stderr)
def test_refresh_has_full_history_read_only_token_and_required_artifact(self):
"""Keep refresh read-only and publish the validated file under its output name."""
workflow = load('refresh-contributors.yml')
self.assertEqual(workflow['permissions'], {'contents': 'read', 'pull-requests': 'read'})
steps = workflow['jobs']['refresh']['steps']
checkout = steps[0]
self.assertEqual(checkout['with']['fetch-depth'], '0')
self.assertEqual(checkout['with']['persist-credentials'], 'false')
refresh = next(s for s in steps if 'run' in s)
self.assertEqual(refresh['env']['GH_TOKEN'], '${{ github.token }}')
upload = steps[-1]
self.assertIn('actions/upload-artifact@', upload['uses'])
self.assertEqual(upload['with']['if-no-files-found'], 'error')
self.assertEqual(upload['with']['path'], 'contributors/contributors.json')
self.assertNotIn('continue-on-error', refresh)
self.assertNotIn('continue-on-error', upload)
self.assertEqual(upload['with']['name'], workflow['jobs']['refresh']['outputs']['artifact-name'])
def test_old_callers_keep_the_same_permissions_and_skip_artifact_download(self):
"""Leave permissions and checked-in credits unchanged unless callers opt in."""
workflow = load('release.yml')
self.assertEqual(workflow['permissions'], {'contents': 'read'})
self.assertEqual(workflow['on']['workflow_call']['inputs']['contributors-artifact']['default'], '')
for job in workflow['jobs'].values():
for step in job.get('steps', []):
if step.get('name') == 'Load refreshed contributor credits':
self.assertEqual(step['if'], "inputs.contributors-artifact != ''")
def test_every_source_build_consumes_one_snapshot_before_compilation(self):
"""Install the named snapshot before all seven source builds start."""
jobs = load('release.yml')['jobs']
for name in ('macos', 'windows', 'linux', 'freebsd', 'web', 'linux-riscv64', 'server'):
with self.subTest(job=name):
steps = jobs[name]['steps']
download = next(i for i, s in enumerate(steps) if s.get('name') == 'Load refreshed contributor credits')
self.assertIn('actions/checkout@', steps[download - 1]['uses'])
settings = steps[download]['with']
self.assertEqual(settings['name'], '${{ inputs.contributors-artifact }}')
self.assertEqual(settings['path'], 'contributors')
build_steps = [i for i, s in enumerate(steps)
if 'cargo ' in s.get('run', '') or 'packaging/' in s.get('run', '')
or 'vmactions/freebsd-vm@' in s.get('uses', '')]
self.assertTrue(build_steps)
self.assertTrue(all(download < i for i in build_steps))
self.assertNotIn('continue-on-error', steps[download])
# These package already-built Linux binaries rather than compiling them again.
for name in ('flatpak', 'arch'):
self.assertIn('linux', jobs[name]['needs'])
def test_snapshots_stay_out_of_draft_release_assets_and_checksums(self):
"""Exclude both rerun snapshots while collecting, checksumming and uploading packages."""
refresh = load('refresh-contributors.yml')['jobs']['refresh']
snapshot_name = refresh['steps'][-1]['with']['name']
artifacts = {
snapshot_name.replace('${{ github.run_id }}', '123')
.replace('${{ github.run_attempt }}', str(attempt)):
{'contributors.json': f'credits from attempt {attempt}'.encode()}
for attempt in (1, 2)
}
packages = {}
for job, settings in load('release.yml')['jobs'].items():
for step in settings.get('steps', []):
if step.get('uses', '').startswith('actions/upload-artifact@'):
name = step['with']['name'].replace('${{ matrix.arch }}', 'x86_64')
filename = f'fixture-{job}.tar.gz'
packages[filename] = f'package from {job}'.encode()
artifacts[name] = {filename: packages[filename]}
self.assertTrue(packages, 'The fixture must include real release artifact producers')
action = yaml.load((ROOT / 'actions/draft-release/action.yml').read_text(),
Loader=yaml.BaseLoader)
steps = action['runs']['steps']
download = next(s for s in steps if s.get('uses', '').startswith('actions/download-artifact@'))
self.assertEqual(download['with']['merge-multiple'], 'true')
expected_checksums = {name: hashlib.sha256(data).hexdigest()
for name, data in packages.items()}
for is_draft in (False, True):
with self.subTest(existing_draft=is_draft), tempfile.TemporaryDirectory() as directory:
root = Path(directory)
destination = root / download['with']['path']
destination.mkdir()
# Model the action's actual glob and flattened artifact download.
for name, files in artifacts.items():
if fnmatch.fnmatchcase(name, download['with']['pattern']):
for filename, data in files.items():
(destination / filename).write_bytes(data)
tools = root / 'tools'
tools.mkdir()
gh = tools / 'gh'
gh.write_text(f'#!{sys.executable}\n' + '''"""Capture release uploads without contacting GitHub."""
import json, os, sys
from pathlib import Path
args = sys.argv[1:]
if args[:2] == ['release', 'view']:
if args[args.index('--json') + 1] == 'isDraft':
if os.environ['EXISTING_DRAFT'] != 'true':
raise SystemExit(1)
print('true')
elif args[:2] in (['release', 'create'], ['release', 'upload']):
Path(os.environ['UPLOAD_LOG']).write_text(json.dumps(args))
''')
gh.chmod(0o755)
env = dict(os.environ, PATH=str(tools) + os.pathsep + os.environ['PATH'],
VERSION='1.2.3', APP_TITLE='Fixture', PRERELEASE='false',
GITHUB_SHA='release-commit', GITHUB_STEP_SUMMARY=str(root / 'summary'),
EXISTING_DRAFT=str(is_draft).lower(), UPLOAD_LOG=str(root / 'upload.json'))
for step in steps:
if 'run' in step:
result = subprocess.run(['bash', '-e', '-o', 'pipefail', '-c', step['run']],
cwd=root / step.get('working-directory', '.'),
env=env, text=True, capture_output=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stderr)
checksums = dict((line.split()[1], line.split()[0])
for line in (destination / 'SHA256SUMS.txt').read_text().splitlines())
self.assertEqual(checksums, expected_checksums)
args = json.loads((root / 'upload.json').read_text())
self.assertEqual(args[:2], ['release', 'upload' if is_draft else 'create'])
self.assertEqual(set(args[args.index('--') + 1:]), set(packages) | {'SHA256SUMS.txt'})
if __name__ == '__main__':
unittest.main()