From 3cb27117d099d77a6b91879dbdbbf8490ffd3d92 Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Fri, 30 Oct 2026 14:36:32 +0000 Subject: [PATCH 1/3] 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() From 215a7f48c1e01af90b27a0ac2847228d381dc2d8 Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Fri, 30 Oct 2026 18:00:34 +0100 Subject: [PATCH 2/3] fix(release): exclude contributor snapshots from release assets --- .github/workflows/refresh-contributors.yml | 4 +- README.md | 4 +- tests/test_contributor_refresh.py | 81 ++++++++++++++++++++++ 3 files changed, 86 insertions(+), 3 deletions(-) diff --git a/.github/workflows/refresh-contributors.yml b/.github/workflows/refresh-contributors.yml index 8d10500..e281e05 100644 --- a/.github/workflows/refresh-contributors.yml +++ b/.github/workflows/refresh-contributors.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 15 outputs: - artifact-name: release-contributors-${{ github.run_id }}-${{ github.run_attempt }} + artifact-name: contributor-snapshot-${{ github.run_id }}-${{ github.run_attempt }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -44,7 +44,7 @@ jobs: - 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 }} + name: contributor-snapshot-${{ github.run_id }}-${{ github.run_attempt }} path: contributors/contributors.json if-no-files-found: error retention-days: 7 diff --git a/README.md b/README.md index b48e841..be9b132 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,9 @@ jobs: 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 +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. diff --git a/tests/test_contributor_refresh.py b/tests/test_contributor_refresh.py index d81563e..12e8fc7 100644 --- a/tests/test_contributor_refresh.py +++ b/tests/test_contributor_refresh.py @@ -1,8 +1,11 @@ """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 @@ -12,11 +15,13 @@ 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: @@ -46,16 +51,19 @@ Path('contributors/contributors.json').write_text(json.dumps(credits)) 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) @@ -63,6 +71,7 @@ Path('contributors/contributors.json').write_text(json.dumps(credits)) 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'] @@ -80,6 +89,7 @@ Path('contributors/contributors.json').write_text(json.dumps(credits)) 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'], '') @@ -89,6 +99,7 @@ Path('contributors/contributors.json').write_text(json.dumps(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): @@ -108,6 +119,76 @@ Path('contributors/contributors.json').write_text(json.dumps(credits)) 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() From b97dadbcbea69e2cc6864e93ac0eb19c3dd90776 Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Fri, 30 Oct 2026 21:28:16 +0100 Subject: [PATCH 3/3] docs(release): clarify contributor refresh caller permissions --- README.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index be9b132..28a94fb 100644 --- a/README.md +++ b/README.md @@ -68,9 +68,15 @@ jobs: #### 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: +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: