Release: support automatic contributor snapshot refresh

This commit is contained in:
Kristian Myram committed 2026-10-30 14:36:32 +00:00
1 parent 2b3fb9dc05
commit 3cb27117d0
5 files changed
+246

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: release-contributors-${{ 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: release-contributors-${{ 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
@@ -404,6 +415,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
@@ -509,6 +526,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
@@ -771,6 +794,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
@@ -1014,6 +1043,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
@@ -1112,6 +1147,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
@@ -1183,6 +1224,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/**
+34
View File
@@ -65,6 +65,40 @@ 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.
Call the read-only preparatory workflow, then pass its output to the release workflow:
```yaml
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. 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.
+113
View File
@@ -0,0 +1,113 @@
"""Exercise release credit refresh failure handling and artifact distribution."""
import json
import os
from pathlib import Path
import subprocess
import tempfile
import unittest
import yaml
ROOT = Path(__file__).resolve().parents[1]
def load(name):
return yaml.load((ROOT / '.github/workflows' / name).read_text(), Loader=yaml.BaseLoader)
class ContributorRefreshTests(unittest.TestCase):
def run_refresh(self, mode):
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):
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):
result, snapshot = self.run_refresh('failed')
self.assertEqual(result.returncode, 23)
self.assertIsNone(snapshot)
def test_rejects_stale_wrong_repository_and_empty_snapshots(self):
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):
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):
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):
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'])
if __name__ == '__main__':
unittest.main()