mirror of
https://github.com/storytold/craft-common-actions.git
synced 2026-11-09 04:01:41 +00:00
Merge pull request #110 from nanox333/codex/release-contributor-refresh
Release: refresh contributor credits automatically before builds
This commit is contained in:
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
|
||||
@@ -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
|
||||
|
||||
@@ -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/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user