diff --git a/.github/workflows/refresh-contributors.yml b/.github/workflows/refresh-contributors.yml new file mode 100644 index 0000000..e281e05 --- /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: 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d007116..dbb16c5 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 @@ -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 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 663551c..d7a6d8f 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/tests/test_contributor_refresh.py b/tests/test_contributor_refresh.py new file mode 100644 index 0000000..12e8fc7 --- /dev/null +++ b/tests/test_contributor_refresh.py @@ -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()