mirror of
https://github.com/storytold/craft-common-actions.git
synced 2026-11-11 13:04:51 +00:00
Release: support automatic contributor snapshot refresh
This commit is contained in:
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
|
||||
@@ -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
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user