From 3cb27117d099d77a6b91879dbdbbf8490ffd3d92 Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Fri, 30 Oct 2026 14:36:32 +0000 Subject: [PATCH] Release: support automatic contributor snapshot refresh --- .github/workflows/refresh-contributors.yml | 50 +++++++++ .github/workflows/release.yml | 47 +++++++++ .github/workflows/test-freebsd.yml | 2 + README.md | 34 +++++++ tests/test_contributor_refresh.py | 113 +++++++++++++++++++++ 5 files changed, 246 insertions(+) create mode 100644 .github/workflows/refresh-contributors.yml create mode 100644 tests/test_contributor_refresh.py diff --git a/.github/workflows/refresh-contributors.yml b/.github/workflows/refresh-contributors.yml new file mode 100644 index 0000000..8d10500 --- /dev/null +++ b/.github/workflows/refresh-contributors.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e5473b6..91145d9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/.github/workflows/test-freebsd.yml b/.github/workflows/test-freebsd.yml index 805dcf0..86a0255 100644 --- a/.github/workflows/test-freebsd.yml +++ b/.github/workflows/test-freebsd.yml @@ -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/** diff --git a/README.md b/README.md index 4c2ce96..b48e841 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/tests/test_contributor_refresh.py b/tests/test_contributor_refresh.py new file mode 100644 index 0000000..d81563e --- /dev/null +++ b/tests/test_contributor_refresh.py @@ -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()